diff --git a/crates/dxf/src/model.rs b/crates/dxf/src/model.rs index e7b42db8b..01e5146e2 100644 --- a/crates/dxf/src/model.rs +++ b/crates/dxf/src/model.rs @@ -22,6 +22,7 @@ mod entity; mod hatch; pub mod mleader; pub mod objects; +pub mod table; mod tables; pub mod text; pub mod write; diff --git a/crates/dxf/src/model/entity.rs b/crates/dxf/src/model/entity.rs index b1d8271a5..84748cbbc 100644 --- a/crates/dxf/src/model/entity.rs +++ b/crates/dxf/src/model/entity.rs @@ -532,6 +532,8 @@ pub enum EntityKind { MLine(MLine), /// Read only: [`super::write`] leaves it out (pass the record as raw tags). MLeader(super::mleader::MLeader), + /// Read only: [`super::write`] leaves it out (pass the record as raw tags). + Table(super::table::Table), Ray { base: Point, direction: Point, @@ -800,6 +802,7 @@ impl Entity { EntityKind::MLine(mline(own.0, &own)) } "MULTILEADER" | "MLEADER" => EntityKind::MLeader(super::mleader::MLeader::read(e.tags)), + "ACAD_TABLE" => EntityKind::Table(super::table::Table::read(e.tags)), "RAY" => EntityKind::Ray { base: line(&g).0, direction: line(&g).1 }, "XLINE" => EntityKind::XLine { base: line(&g).0, direction: line(&g).1 }, "TEXT" => EntityKind::Text(Text::read(&g)), diff --git a/crates/dxf/src/model/table.rs b/crates/dxf/src/model/table.rs new file mode 100644 index 000000000..ae77aae19 --- /dev/null +++ b/crates/dxf/src/model/table.rs @@ -0,0 +1,229 @@ +//! ACAD_TABLE and TABLESTYLE. +//! +//! An ACAD_TABLE's own groups (after `AcDbTable`) give its size, its row heights and column +//! widths, then one run of groups per cell, each started by a `171` (the cell type), row by row. +//! The table is drawn by its anonymous `*T` block (group 2), which a reader can show instead. +//! +//! From CADCraft's `cadcraft-io` (`dxf_read/entities.rs` `acad_table` and `dxf_table.rs` +//! `read_table_style`, 5a6aa9b6). + +use super::document::Record; +use super::{Color, Groups, Lineweight, Point}; +use crate::Tag; + +/// Most rows, and most columns, a table keeps. +pub const MAX_SIDE: usize = 10_000; +/// Most cells a table keeps. +pub const MAX_CELLS: usize = 1_000_000; + +/// A cell of an ACAD_TABLE. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct TableCell { + /// Group 171: 1 text, 2 a block. + pub cell_type: i64, + /// Groups 2 and 3 in order, then 1: the text as written. + pub text: String, + /// Groups 176 (rows) and 175 (columns): the span of a merge that starts here. + pub span: Option<(u32, u32)>, + /// Group 173 is not 0: the cell is part of a merge. Writers differ on whether the first cell + /// of a merge has it. + pub merged: bool, + /// Group 140 outside the value block (`301` ... `304`, where 140 is a number): the cell's + /// own text height. + pub text_height: Option, + /// Group 170: the alignment, 1–9. + pub alignment: Option, + /// Group 7: the cell's text style name. + pub text_style: Option, + /// Group 340: the block of a block cell. + pub block: Option, +} + +/// An ACAD_TABLE. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Table { + /// Group 2: the anonymous block that draws the table. + pub block: Option, + /// Group 342: the TABLESTYLE. + pub style: Option, + /// Group 10. + pub insert: Point, + /// Group 11: the horizontal direction. + pub direction: Point, + /// Groups 91 and 92: the rows and columns, as the file states them. + pub rows: usize, + pub columns: usize, + /// Groups 141 and 142, as many as there are rows and columns at most. + pub row_heights: Vec, + pub column_widths: Vec, + /// The cells there are, row by row: at most `rows * columns` of them. + pub cells: Vec, +} + +impl Table { + /// The cell at `row`, `column`, when the file has it. + pub fn cell(&self, row: usize, column: usize) -> Option<&TableCell> { + self.cells.get(row.checked_mul(self.columns)?.checked_add(column)?) + } + + /// The typed form of an ACAD_TABLE record. A table larger than [`MAX_SIDE`] a side or + /// [`MAX_CELLS`] in all, or with no rows or no columns, keeps neither: its size reads as 0 + /// by 0, with no cells, row heights or column widths. A file states its size, and only cells + /// it holds are kept, so a large declared size costs nothing. + pub fn read(tags: &[Tag]) -> Table { + let all = Groups(tags); + let start = tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == "AcDbTable")).unwrap_or(0); + let tail = tags.get(start..).unwrap_or(&[]); + let first_cell = tail.iter().position(|t| t.code == 171).unwrap_or(tail.len()); + let head = Groups(tail.get(..first_cell).unwrap_or(&[])); + let side = |code: i32| usize::try_from(head.i_or(code, 0)).ok().filter(|n| *n <= MAX_SIDE).unwrap_or(0); + let (mut rows, mut columns) = (side(91), side(92)); + let cells_max = rows.saturating_mul(columns); + if cells_max == 0 || cells_max > MAX_CELLS { + (rows, columns) = (0, 0); + } + let mut t = Table { + block: all.name(2), + style: all.handle(342).filter(|h| *h != 0), + insert: all.point(10), + direction: all.point_opt(11).unwrap_or([1.0, 0.0, 0.0]), + rows, + columns, + row_heights: head.0.iter().filter(|t| t.code == 141).take(rows).map(Tag::f64).collect(), + column_widths: head.0.iter().filter(|t| t.code == 142).take(columns).map(Tag::f64).collect(), + cells: Vec::new(), + }; + // Cells, row by row, each from a 171 group; extended data ends the last one. + let body = tail.get(first_cell..).unwrap_or(&[]); + let body = body.get(..body.iter().position(|t| t.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); + let starts: Vec = body.iter().enumerate().filter(|(_, t)| t.code == 171).map(|(i, _)| i).take(rows * columns).collect(); + for (k, s) in starts.iter().enumerate() { + let end = starts + .get(k + 1) + .copied() + .unwrap_or_else(|| body.get(s + 1..).and_then(|r| r.iter().position(|t| t.code == 171)).map_or(body.len(), |p| s + 1 + p)); + let cell = body.get(*s..end).unwrap_or(&[]); + t.cells.push(read_cell(cell)); + } + t + } +} + +fn read_cell(cell: &[Tag]) -> TableCell { + let g = Groups(cell); + let mut text: String = cell.iter().filter(|t| t.code == 2 || t.code == 3).map(Tag::str).collect(); + text.push_str(&g.s(1).unwrap_or_default()); + let span = |code: i32| g.i(code).and_then(|v| u32::try_from(v).ok()); + // The cell's own text height: 140 outside the value block, where it is a number. + let mut in_value = false; + let mut text_height = None; + for t in cell { + match t.code { + 301 => in_value = true, + 304 => in_value = false, + 140 if !in_value && text_height.is_none() => text_height = Some(t.f64()), + _ => {} + } + } + TableCell { + cell_type: g.i_or(171, 1), + text, + span: span(176).zip(span(175)), + merged: g.i_or(173, 0) != 0, + text_height, + alignment: g.i(170), + text_style: g.name(7), + block: g.handle(340).filter(|h| *h != 0), + } +} + +/// One edge of a table cell style: top, right, bottom, left, inside horizontal, inside vertical. +#[derive(Clone, Debug, PartialEq)] +pub struct Border { + /// Groups 274 to 279. + pub lineweight: Option, + /// Groups 284 to 289 (shown unless 0). + pub visible: Option, + /// Groups 64 to 69. + pub color: Option, +} + +/// The groups of one cell type of a TABLESTYLE (data, header, title, in that order). +#[derive(Clone, Debug, PartialEq)] +pub struct CellTypeStyle { + /// Group 7: the text style name. + pub text_style: String, + /// Group 140. + pub text_height: Option, + /// Group 170: the alignment, 1–9. + pub alignment: Option, + /// Group 62. + pub text_color: Option, + /// Group 283 is not 0 and group 63: the fill colour. + pub fill: Option, + pub borders: [Border; 6], +} + +/// A TABLESTYLE. +#[derive(Clone, Debug, PartialEq)] +pub struct TableStyle { + pub handle: Option, + /// Group 3. + pub description: String, + /// Group 70: the flow direction (0 down, 1 up). + pub flow: i64, + /// Groups 40 and 41: the horizontal and vertical cell margins. + pub margin_h: Option, + pub margin_v: Option, + /// Groups 280 and 281: the title and the header row are suppressed (not 0). + pub title_suppressed: bool, + pub header_suppressed: bool, + /// Data, header and title, as many as the record has. + pub cell_types: Vec, +} + +impl TableStyle { + pub fn read(r: &Record<'_>) -> TableStyle { + let start = r.tags.iter().position(|t| t.code == 100 && t.str_ref().is_some_and(|s| s.trim() == "AcDbTableStyle")).unwrap_or(0); + let body = r.tags.get(start..).unwrap_or(&[]); + let body = body.get(..body.iter().position(|t| t.code >= 1000).unwrap_or(body.len())).unwrap_or(&[]); + // Groups up to the first cell type (code 7) are the style's own. + let head_end = body.iter().position(|t| t.code == 7).unwrap_or(body.len()); + let head = Groups(body.get(..head_end).unwrap_or(&[])); + let starts: Vec = body.iter().enumerate().filter(|(_, t)| t.code == 7).map(|(i, _)| i).take(3).collect(); + let cell_types = starts + .iter() + .enumerate() + .map(|(k, s)| { + let end = starts + .get(k + 1) + .copied() + .unwrap_or_else(|| body.get(s + 1..).and_then(|x| x.iter().position(|t| t.code == 7)).map_or(body.len(), |p| s + 1 + p)); + let g = Groups(body.get(*s..end).unwrap_or(&[])); + let border = |k: i32| Border { + lineweight: g.i(274 + k).map(Lineweight::from_dxf), + visible: g.i(284 + k).map(|v| v != 0), + color: g.i(64 + k).map(Color::from_index), + }; + CellTypeStyle { + text_style: g.s(7).unwrap_or_default().trim().to_string(), + text_height: g.f(140), + alignment: g.i(170), + text_color: g.i(62).map(Color::from_index), + fill: (g.i_or(283, 0) != 0).then(|| g.i(63).map_or(Color::Index(7), Color::from_index)), + borders: [0, 1, 2, 3, 4, 5].map(border), + } + }) + .collect(); + TableStyle { + handle: r.handle(), + description: head.s(3).unwrap_or_default(), + flow: head.i_or(70, 0), + margin_h: head.f(40), + margin_v: head.f(41), + title_suppressed: head.i_or(280, 0) != 0, + header_suppressed: head.i_or(281, 0) != 0, + cell_types, + } + } +} diff --git a/crates/dxf/src/model/write.rs b/crates/dxf/src/model/write.rs index 91c08cffa..8a33731d2 100644 --- a/crates/dxf/src/model/write.rs +++ b/crates/dxf/src/model/write.rs @@ -748,7 +748,7 @@ impl W { } /// The groups of an entity after its common properties; `false` for a kind that has none - /// here ([`EntityKind::MLeader`] and [`EntityKind::Other`]). + /// here ([`EntityKind::MLeader`], [`EntityKind::Table`] and [`EntityKind::Other`]). fn geometry(&mut self, kind: &EntityKind, c: &Common) -> bool { match kind { EntityKind::Line { start, end } => { @@ -884,7 +884,7 @@ impl W { } self.p(11, *direction); } - EntityKind::MLeader(_) | EntityKind::Other => return false, + EntityKind::MLeader(_) | EntityKind::Table(_) | EntityKind::Other => return false, } true } @@ -932,7 +932,7 @@ fn kind_name(kind: &EntityKind) -> Option<&'static str> { EntityKind::Image(_) => "IMAGE", EntityKind::Wipeout(_) => "WIPEOUT", EntityKind::Tolerance { .. } => "TOLERANCE", - EntityKind::MLeader(_) | EntityKind::Other => return None, + EntityKind::MLeader(_) | EntityKind::Table(_) | EntityKind::Other => return None, }) } @@ -1051,7 +1051,7 @@ impl Ids { /// The tags of one entity (with its vertices or attributes and their SEQEND) for `format`, owned /// by `owner`: for a caller that writes its own file around them. Its handle is the entity's, or /// one from `next_handle`; followers get handles from `next_handle` too, which is moved past -/// those used. Empty for [`EntityKind::MLeader`] and [`EntityKind::Other`]. +/// those used. Empty for [`EntityKind::MLeader`], [`EntityKind::Table`] and [`EntityKind::Other`]. pub fn entity_tags(e: &Entity, format: Format, owner: u64, next_handle: &mut u64) -> Vec { let mut w = W { v: format, t: Vec::new() }; let mut ids = Ids { used: HashSet::new(), next: *next_handle, floor: 0 }; @@ -1139,7 +1139,7 @@ const PAPER: &str = "*Paper_Space"; impl Drawing<'_> { /// The whole file as tags, ending with `0 EOF`, and how many entities were left out - /// ([`EntityKind::MLeader`] and [`EntityKind::Other`], which it has no groups for: pass + /// ([`EntityKind::MLeader`], [`EntityKind::Table`] and [`EntityKind::Other`], which it has no groups for: pass /// those in `raw_entities`). pub fn tags(&self) -> (Vec, usize) { let v = self.format; diff --git a/crates/dxf/tests/model_table.rs b/crates/dxf/tests/model_table.rs new file mode 100644 index 000000000..fa4d5b6e9 --- /dev/null +++ b/crates/dxf/tests/model_table.rs @@ -0,0 +1,101 @@ +//! ACAD_TABLE and TABLESTYLE. + +use craft_dxf::model::table::{MAX_SIDE, Table, TableStyle}; +use craft_dxf::model::{Color, Document, Entity, EntityKind, Lineweight, entity_records, records}; +use craft_dxf::{Tag, parse}; + +fn tags(text: &str) -> Vec { + let lines: Vec<&str> = text.split(['\n', '|']).map(str::trim).filter(|l| !l.is_empty()).map(|l| if l == "~" { "" } else { l }).collect(); + parse((lines.join("\n") + "\n").as_bytes()).expect("the test drawing parses") +} + +const TABLE: &str = "0|ACAD_TABLE|5|90|100|AcDbEntity|8|0|100|AcDbBlockReference|2|*T1|10|1.0|20|2.0|30|0.0|100|AcDbTable|280|0|342|5A|343|*T1|11|1.0|21|0.0|31|0.0 +90|22|91|2|92|2|93|0|94|0|95|0|96|0|141|0.5|141|0.7|142|3.0|142|4.0 +171|1|172|0|173|0|174|0|175|2|176|1|91|0|178|0|145|0.0|92|0|301|CELL_VALUE|93|6|90|4|1|Title|94|0|140|99.0|300|~|302|Title|304|ACVALUE_END|140|2.5|170|5|7|Bold +171|1|172|0|173|1|174|0|175|0|176|0|91|0|178|0 +171|1|172|0|173|0|174|0|175|1|176|1|2|first-|3|part-|1|last +171|2|172|0|173|0|174|0|175|1|176|1|340|B1 +1001|CADCRAFT|1000|TABLE"; + +fn read(text: &str) -> Table { + let t = tags(text); + let (e, _) = entity_records(&records(&t)); + let EntityKind::Table(table) = Entity::read(&e[0]).kind else { panic!("a table") }; + table +} + +#[test] +fn a_table_and_its_cells() { + let t = read(TABLE); + assert_eq!((t.block.as_deref(), t.style, t.insert, t.direction), (Some("*T1"), Some(0x5A), [1.0, 2.0, 0.0], [1.0, 0.0, 0.0])); + assert_eq!((t.rows, t.columns, t.row_heights.as_slice(), t.column_widths.as_slice()), (2, 2, [0.5, 0.7].as_slice(), [3.0, 4.0].as_slice())); + assert_eq!(t.cells.len(), 4); + let title = t.cell(0, 0).expect("a cell"); + // The 140 inside the value block is a number, not the height; the extended data ends the last cell. + assert_eq!( + (title.text.as_str(), title.span, title.merged, title.text_height, title.alignment, title.text_style.as_deref()), + ("Title", Some((1, 2)), false, Some(2.5), Some(5), Some("Bold")) + ); + assert_eq!((t.cell(0, 1).map(|c| c.merged), t.cell(0, 1).map(|c| c.span)), (Some(true), Some(Some((0, 0))))); + assert_eq!(t.cell(1, 0).map(|c| c.text.as_str()), Some("first-part-last")); + let block = t.cell(1, 1).expect("a cell"); + assert_eq!((block.cell_type, block.block, block.text.as_str()), (2, Some(0xB1), "")); + assert_eq!((t.cell(2, 0), t.cell(usize::MAX, 2)), (None, None)); +} + +#[test] +fn a_size_a_table_cannot_have_keeps_nothing() { + for (r, c) in [(0, 5), (5, 0), (MAX_SIDE + 1, 1), (2000, 2000)] { + let t = read(&format!("0|ACAD_TABLE|100|AcDbTable|91|{r}|92|{c}|141|1.0|142|1.0|171|1|1|x")); + assert_eq!((t.rows, t.columns, t.cells.len(), t.row_heights.len(), t.column_widths.len()), (0, 0, 0, 0, 0), "{r} x {c}"); + } + // A large size the file backs with no cells costs nothing. + let t = read("0|ACAD_TABLE|100|AcDbTable|91|1000|92|1000|171|1|1|only"); + assert_eq!((t.rows, t.columns, t.cells.len()), (1000, 1000, 1)); + // More cells than the size are left out. + let t = read("0|ACAD_TABLE|100|AcDbTable|91|1|92|1|171|1|1|a|171|1|1|b"); + assert_eq!(t.cells.len(), 1); + assert_eq!(read("0|ACAD_TABLE|91|-3|92|2").rows, 0); +} + +#[test] +fn a_table_style() { + let t = tags( + "0|SECTION|2|OBJECTS|0|TABLESTYLE|5|5A|100|AcDbTableStyle|3|Plain|70|0|71|0|40|0.06|41|0.07|280|0|281|1 +7|Standard|140|0.18|170|5|62|1|63|7|283|1|90|512|91|1|274|-2|275|50|284|1|285|0|64|2|65|0 +7|Bold|140|0.25|170|2|62|0|283|0 +7|Title|140|0.3 +1001|CADCRAFT|1000|CELLSTYLES|0|ENDSEC|0|EOF", + ); + let d = Document::read(&t); + let s = TableStyle::read(d.objects_of("TABLESTYLE").next().expect("the style")); + assert_eq!( + (s.handle, s.description.as_str(), s.flow, s.margin_h, s.margin_v, s.title_suppressed, s.header_suppressed), + (Some(0x5A), "Plain", 0, Some(0.06), Some(0.07), false, true) + ); + assert_eq!(s.cell_types.len(), 3); + let data = &s.cell_types[0]; + assert_eq!( + (data.text_style.as_str(), data.text_height, data.alignment, data.text_color, data.fill), + ("Standard", Some(0.18), Some(5), Some(Color::Index(1)), Some(Color::Index(7))) + ); + assert_eq!( + (data.borders[0].lineweight, data.borders[1].lineweight, data.borders[0].visible, data.borders[1].visible), + (Some(Lineweight::ByBlock), Some(Lineweight::Hundredths(50)), Some(true), Some(false)) + ); + assert_eq!((data.borders[0].color, data.borders[1].color, data.borders[2].color), (Some(Color::Index(2)), Some(Color::ByBlock), None)); + let header = &s.cell_types[1]; + assert_eq!((header.text_style.as_str(), header.fill, header.text_color), ("Bold", None, Some(Color::ByBlock))); + assert_eq!(s.cell_types[2].text_height, Some(0.3)); + let bare = tags("0|SECTION|2|OBJECTS|0|TABLESTYLE|0|ENDSEC|0|EOF"); + let empty = Document::read(&bare); + assert!(TableStyle::read(empty.objects_of("TABLESTYLE").next().expect("the style")).cell_types.is_empty()); +} + +#[test] +fn the_writer_leaves_a_table_out() { + let t = tags(TABLE); + let e = Entity::read(&entity_records(&records(&t)).0[0]); + let (_, skipped) = craft_dxf::model::write::Drawing { entities: vec![e], ..Default::default() }.tags(); + assert_eq!(skipped, 1); +}