diff --git a/crates/dxf/src/model/entity.rs b/crates/dxf/src/model/entity.rs index 84748cbbc..632042cf6 100644 --- a/crates/dxf/src/model/entity.rs +++ b/crates/dxf/src/model/entity.rs @@ -530,9 +530,9 @@ pub enum EntityKind { Spline(Spline), Helix(Helix), MLine(MLine), - /// Read only: [`super::write`] leaves it out (pass the record as raw tags). + /// Written from the 2007 format on. MLeader(super::mleader::MLeader), - /// Read only: [`super::write`] leaves it out (pass the record as raw tags). + /// Written from the 2004 format on. Table(super::table::Table), Ray { base: Point, diff --git a/crates/dxf/src/model/mleader.rs b/crates/dxf/src/model/mleader.rs index 5d0ed6296..fb3f241aa 100644 --- a/crates/dxf/src/model/mleader.rs +++ b/crates/dxf/src/model/mleader.rs @@ -70,6 +70,8 @@ pub struct MLeader { pub text_height: Option, /// Group 140 of the context: the arrow size. pub context_arrow_size: Option, + /// Group 145 of the context: the landing gap. + pub landing_gap: Option, /// Group 290 of the context: there is text. pub has_text: Option, /// Group 304 of the context: the text's contents, decoded as an MTEXT's are. @@ -215,6 +217,7 @@ impl MLeader { 10 | 20 | 30 => pts.base.take(10, c, f), 41 => m.text_height = Some(f), 140 => m.context_arrow_size = Some(f), + 145 => m.landing_gap = Some(f), 290 => m.has_text = Some(t.i64() != 0), 304 => m.text = Some(decode_contents(&t.str())), 340 => m.context_text_style = handle(t), diff --git a/crates/dxf/src/model/write.rs b/crates/dxf/src/model/write.rs index 42ee1d155..3ee234153 100644 --- a/crates/dxf/src/model/write.rs +++ b/crates/dxf/src/model/write.rs @@ -14,10 +14,12 @@ //! The file skeleton is VectorCraft's (`vectorcraft-cad`, `writer.rs`); the groups of each //! entity follow the DXF Reference, as CADCraft's `dxf_write` writes them. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use super::entity::{Attrib, Common, Dimension, Entity, EntityKind, Image, Insert, Leader, LwPolyline, Polyline, Spline, Text, Viewport}; use super::hatch::{Hatch, HatchEdge, HatchPath}; +use super::mleader::{MLeader, MLeaderStyle}; +use super::table::{Table, TableStyle}; use super::tables::{DimStyle, Layer, Linetype, TextStyle}; use super::text::{MText, encode_contents, encode_line}; use super::{Color, Lineweight, Point, Transparency, aci}; @@ -134,6 +136,8 @@ pub mod fixed { struct W { v: Format, t: Vec, + /// Upper-case block name → its BLOCK_RECORD handle (an ACAD_TABLE's group 343). + blocks: HashMap, } impl W { @@ -748,7 +752,7 @@ impl W { } /// The groups of an entity after its common properties; `false` for a kind that has none - /// here ([`EntityKind::MLeader`], [`EntityKind::Table`] and [`EntityKind::Other`]). + /// here ([`EntityKind::Other`]). fn geometry(&mut self, kind: &EntityKind, c: &Common) -> bool { match kind { EntityKind::Line { start, end } => { @@ -884,12 +888,338 @@ impl W { } self.p(11, *direction); } - EntityKind::MLeader(_) | EntityKind::Table(_) | EntityKind::Other => return false, + EntityKind::MLeader(m) => self.mleader(m), + EntityKind::Table(t) => self.acad_table(t, c), + EntityKind::Other => return false, } true } } +impl W { + fn opt_p(&mut self, code: i32, p: Option) { + if let Some(p) = p { + self.p(code, p); + } + } + fn opt_f(&mut self, code: i32, v: Option) { + if let Some(v) = v { + self.f(code, v); + } + } + fn opt_i(&mut self, code: i32, v: Option) { + if let Some(v) = v { + self.i(code, v); + } + } + fn opt_h(&mut self, code: i32, v: Option) { + if let Some(v) = v { + self.h(code, v); + } + } + + /// A MULTILEADER's groups after its common properties, in the order and with the fixed + /// values CADCraft's writer uses (`dxf_mleader.rs` `entity_tags`): the context block with its + /// roots and lines, then the entity's own overrides. A value the model does not have is not + /// written. + fn mleader(&mut self, m: &MLeader) { + // ByBlock and "no colour" as 32-bit colour values (0xC1000000, 0xC8000000). + const BYBLOCK: i64 = -1_056_964_608; + const NO_COLOR: i64 = -939_524_096; + let n = |v: usize| i64::try_from(v).unwrap_or(i64::MAX); + self.sub("AcDbMLeader"); + self.i(270, 2); + self.s(300, "CONTEXT_DATA{"); + self.f(40, 1.0); + self.opt_p(10, m.base); + self.opt_f(41, m.text_height); + self.opt_f(140, m.context_arrow_size); + self.opt_f(145, m.landing_gap); + self.opt_i(174, m.context_left_attachment); + self.opt_i(175, m.context_right_attachment); + // The horizontal alignment (176) is the text alignment (171) less one. + self.opt_i(176, m.text_alignment.map(|a| a - 1)); + self.i(177, 1); + self.opt_i(290, m.has_text.map(i64::from)); + if let Some(t) = &m.text { + self.s(304, encode_contents(t)); + self.p(11, [0.0, 0.0, 1.0]); + } + self.opt_h(340, m.context_text_style); + self.opt_p(12, m.text_location); + self.opt_p(13, m.text_direction); + self.opt_f(42, m.text_rotation); + self.opt_f(43, m.text_width); + if m.text.is_some() { + self.f(44, 0.0); + } + self.opt_f(45, m.line_spacing); + self.opt_i(170, m.line_spacing_style); + if m.text.is_some() { + self.i(90, BYBLOCK); + } + self.opt_i(171, m.text_alignment); + if m.text.is_some() { + self.i(172, 1); + self.i(91, NO_COLOR); + self.f(141, 1.5); + for code in [92, 291, 292, 173, 293] { + self.i(code, 0); + } + self.f(142, 0.0); + self.f(143, 0.0); + self.i(294, 0); + self.i(295, 0); + } + self.opt_i(296, m.has_block.map(i64::from)); + self.opt_h(341, m.context_block); + if m.context_block.is_some() { + self.p(14, [0.0, 0.0, 1.0]); + } + self.opt_p(15, m.block_location); + self.opt_p(16, m.context_block_scale); + self.opt_f(46, m.context_block_rotation); + if let Some(at) = m.block_location { + self.i(93, BYBLOCK); + // Block to world, row by row. shortcut: takes the block's base point as its origin, + // as CADCraft's blocks are; a caller with another base adjusts the location. + let [sx, sy, sz] = m.context_block_scale.unwrap_or([1.0; 3]); + let (sin, cos) = m.context_block_rotation.unwrap_or(0.0).sin_cos(); + for v in [sx * cos, -sy * sin, 0.0, at[0], sx * sin, sy * cos, 0.0, at[1], 0.0, 0.0, sz, at[2], 0.0, 0.0, 0.0, 1.0] { + self.f(47, v); + } + } + self.p(110, [0.0, 0.0, m.base.map_or(0.0, |b| b[2])]); + self.p(111, [1.0, 0.0, 0.0]); + self.p(112, [0.0, 1.0, 0.0]); + self.i(297, 0); + for r in &m.roots { + self.s(302, "LEADER{"); + self.i(290, 1); + self.i(291, 1); + self.opt_p(10, r.landing); + self.opt_p(11, r.direction); + self.i(90, 0); + self.opt_f(40, r.dogleg); + for (k, l) in r.lines.iter().enumerate() { + self.s(304, "LEADER_LINE{"); + for v in l { + self.p(10, *v); + } + self.i(91, n(k)); + self.s(305, "}"); + } + self.i(271, 0); + self.s(303, "}"); + } + self.i(272, 9); + self.i(273, 9); + self.s(301, "}"); + self.opt_h(340, m.style); + self.i(90, 0x1 | 0x10 | 0x40 | 0x80 | 0x200 | 0x400 | 0x800 | 0x1_0000 | 0x8_0000); + self.opt_i(170, m.leader_type); + self.i(91, BYBLOCK); + self.i(171, -2); + self.i(290, 1); + self.opt_i(291, m.dogleg_on.map(i64::from)); + self.opt_f(41, m.dogleg); + self.opt_f(42, m.arrow_size); + self.opt_i(172, m.content_type); + self.opt_h(343, m.text_style); + self.opt_i(173, m.left_attachment); + self.opt_i(95, m.right_attachment); + self.i(174, 1); + self.i(175, m.text_alignment.map_or(0, |a| a - 1)); + self.i(92, BYBLOCK); + self.i(292, 0); + self.opt_h(344, m.block); + if m.block.is_some() { + self.i(93, BYBLOCK); + } + self.opt_p(10, m.block_scale); + self.opt_f(43, m.block_rotation); + if m.block.is_some() { + self.i(176, 1); + } + for (k, (h, text)) in m.attribs.iter().enumerate() { + self.h(330, *h); + self.i(177, n(k)); + self.f(44, 0.0); + self.s(302, text.as_str()); + } + self.i(293, 0); + self.i(294, 0); + self.i(178, 0); + self.i(179, 1); + self.f(45, 1.0); + self.i(271, 0); + self.i(272, 9); + self.i(273, 9); + } + + /// An ACAD_TABLE's groups after its common properties, as CADCraft's writer orders them + /// (`dxf_write/entity.rs`). Group 343 is the record of the table's block when the drawing + /// being written has a block of that name. + fn acad_table(&mut self, t: &Table, c: &Common) { + let n = |v: usize| i64::try_from(v).unwrap_or(i64::MAX); + self.sub("AcDbBlockReference"); + if let Some(b) = &t.block { + self.s(2, b.as_str()); + } + self.p(10, t.insert); + if c.extrusion != [0.0, 0.0, 1.0] { + self.p(210, c.extrusion); + } + self.sub("AcDbTable"); + self.i(280, 0); + self.opt_h(342, t.style); + let record = t.block.as_ref().and_then(|b| self.blocks.get(&b.to_ascii_uppercase()).copied()); + self.opt_h(343, record); + self.p(11, t.direction); + self.i(90, 0); + self.i(91, n(t.rows)); + self.i(92, n(t.columns)); + for code in [93, 94, 95, 96] { + self.i(code, 0); + } + for h in &t.row_heights { + self.f(141, *h); + } + for w in &t.column_widths { + self.f(142, *w); + } + for cell in &t.cells { + self.i(171, cell.cell_type); + self.i(172, 0); + self.i(173, i64::from(cell.merged)); + self.i(174, 0); + // A cell without a span of its own is a single cell. + let (rs, cs) = cell.span.unwrap_or((1, 1)); + self.i(175, i64::from(cs)); + self.i(176, i64::from(rs)); + self.i(91, 0); + self.i(178, 0); + self.f(145, 0.0); + self.opt_f(140, cell.text_height); + self.opt_i(170, cell.alignment); + if let Some(st) = &cell.text_style { + self.s(7, st.as_str()); + } + self.opt_h(340, cell.block); + // Long text: code-2 chunks, the last part in code 1. + let mut chunks = string_groups(&cell.text); + let last = chunks.pop().unwrap_or_default(); + for ch in chunks { + self.s(2, ch); + } + self.s(1, last); + } + } + + /// A MLEADERSTYLE object, with the fixed values CADCraft's writer uses (`dxf_write/objects.rs` + /// `mleader_style_obj`). + fn mleader_style(&mut self, s: &MLeaderStyle, h: u64, dict: u64) { + const BYBLOCK: i64 = -1_056_964_608; + self.owned_object("MLEADERSTYLE", h, dict, "AcDbMLeaderStyle"); + self.i(179, 2); + self.i(170, s.content_type); + self.i(171, 1); + self.i(172, 0); + self.i(90, 2); + self.f(40, 0.0); + self.f(41, 0.0); + self.i(173, s.leader_type); + self.i(91, BYBLOCK); + self.h(340, fixed::BYBLOCK_LTYPE); + self.i(92, -2); + self.i(290, 1); + self.f(42, s.landing_gap); + self.i(291, 1); + self.f(43, s.dogleg); + self.s(3, s.description.as_str()); + self.f(44, s.arrow_size); + self.s(300, ""); + self.opt_h(342, s.text_style); + self.i(174, 1); + self.i(178, 1); + self.i(175, 1); + self.i(176, 0); + self.i(93, BYBLOCK); + self.f(45, s.text_height); + self.i(292, 0); + self.i(297, 0); + self.f(46, 4.0); + self.opt_h(343, s.block); + self.i(94, BYBLOCK); + for code in [47, 49, 140] { + self.f(code, 1.0); + } + self.i(293, 1); + self.f(141, 0.0); + self.i(294, 1); + self.i(177, 0); + self.f(142, 1.0); + self.i(295, 0); + self.i(296, i64::from(s.annotative)); + self.f(143, 0.125); + self.i(271, 0); + self.i(272, 9); + self.i(273, 9); + } + + /// A TABLESTYLE object, as CADCraft's writer orders it (`dxf_write/objects.rs` + /// `table_style_obj`, `dxf_table.rs` `cell_type_tags`). + fn table_style(&mut self, s: &TableStyle, h: u64, dict: u64) { + let color = |c: Color| match c { + Color::ByLayer => 256, + Color::ByBlock => 0, + Color::Index(i) => i64::from(i), + Color::Rgb(rgb) => i64::from(aci::nearest(rgb, 1..=255)), + }; + self.owned_object("TABLESTYLE", h, dict, "AcDbTableStyle"); + self.s(3, s.description.as_str()); + self.i(70, s.flow); + self.i(71, 0); + self.opt_f(40, s.margin_h); + self.opt_f(41, s.margin_v); + self.i(280, i64::from(s.title_suppressed)); + self.i(281, i64::from(s.header_suppressed)); + for ct in &s.cell_types { + self.s(7, ct.text_style.as_str()); + self.opt_f(140, ct.text_height); + self.opt_i(170, ct.alignment); + self.opt_i(62, ct.text_color.map(color)); + match ct.fill { + Some(f) => { + self.i(63, color(f)); + self.i(283, 1); + } + None => self.i(283, 0), + } + for (k, b) in (0..).zip(&ct.borders) { + self.opt_i(274 + k, b.lineweight.map(Lineweight::to_dxf)); + } + for (k, b) in (0..).zip(&ct.borders) { + self.opt_i(284 + k, b.visible.map(i64::from)); + } + for (k, b) in (0..).zip(&ct.borders) { + self.opt_i(64 + k, b.color.map(color)); + } + } + } + + /// An object owned by a dictionary, with that dictionary as its reactor too. + fn owned_object(&mut self, kind: &str, h: u64, dict: u64, subclass: &str) { + self.s(0, kind); + self.h(5, h); + self.s(102, "{ACAD_REACTORS"); + self.h(330, dict); + self.s(102, "}"); + self.h(330, dict); + self.s(100, subclass); + } +} + /// The subclass markers of a POLYLINE with these flags, and of its vertices. fn polyline_subclass(flags: i64) -> (&'static str, &'static str) { if flags & 64 != 0 { @@ -904,7 +1234,7 @@ fn polyline_subclass(flags: i64) -> (&'static str, &'static str) { } /// The record type of an entity kind; `None` for one the writer leaves out. -fn kind_name(kind: &EntityKind) -> Option<&'static str> { +fn kind_name(kind: &EntityKind, format: Format) -> Option<&'static str> { Some(match kind { EntityKind::Line { .. } => "LINE", EntityKind::Point { .. } => "POINT", @@ -932,6 +1262,9 @@ fn kind_name(kind: &EntityKind) -> Option<&'static str> { EntityKind::Image(_) => "IMAGE", EntityKind::Wipeout(_) => "WIPEOUT", EntityKind::Tolerance { .. } => "TOLERANCE", + // MULTILEADER came with the 2007 format, ACAD_TABLE with 2004. + EntityKind::MLeader(_) if format >= Format::R2007 => "MULTILEADER", + EntityKind::Table(_) if format >= Format::R2004 => "ACAD_TABLE", EntityKind::MLeader(_) | EntityKind::Table(_) | EntityKind::Other => return None, }) } @@ -1001,7 +1334,7 @@ impl Ids { } k => k, }; - let Some(name) = kind_name(kind) else { return false }; + let Some(name) = kind_name(kind, w.v) else { return false }; let h = self.take(e.common.handle); w.common(name, &e.common, h, owner); w.geometry(kind, &e.common); @@ -1051,9 +1384,9 @@ 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`], [`EntityKind::Table`] and [`EntityKind::Other`]. +/// those used. Empty for [`EntityKind::Other`], and a MULTILEADER before 2007 or an ACAD_TABLE before 2004. 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 w = W { v: format, t: Vec::new(), blocks: HashMap::new() }; let mut ids = Ids { used: HashSet::new(), next: *next_handle, floor: 0 }; let written = ids.entity(&mut w, e, owner); *next_handle = ids.next.saturating_add(1); @@ -1108,6 +1441,11 @@ pub struct Drawing<'a> { pub raw_classes: Vec, pub raw_objects: Vec, pub root_entries: Vec<(String, u64)>, + /// Multileader styles by name, in the `ACAD_MLEADERSTYLE` dictionary (2007 and later). A + /// MULTILEADER names its style by handle: give the style a handle and the entity the same. + pub mleader_styles: Vec<(String, MLeaderStyle)>, + /// Table styles by name, in the `ACAD_TABLESTYLE` dictionary (2004 and later). + pub table_styles: Vec<(String, TableStyle)>, } impl Default for Drawing<'_> { @@ -1130,6 +1468,8 @@ impl Default for Drawing<'_> { raw_classes: Vec::new(), raw_objects: Vec::new(), root_entries: Vec::new(), + mleader_styles: Vec::new(), + table_styles: Vec::new(), } } } @@ -1139,11 +1479,11 @@ 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`], [`EntityKind::Table`] and [`EntityKind::Other`], which it has no groups for: pass + /// ([`EntityKind::Other`], and a MULTILEADER before 2007 or an ACAD_TABLE before 2004, which it has no groups for: pass /// those in `raw_entities`). pub fn tags(&self) -> (Vec, usize) { let v = self.format; - let mut w = W { v, t: Vec::new() }; + let mut w = W { v, t: Vec::new(), blocks: HashMap::new() }; let entities = self.entities.iter().chain(self.blocks.iter().flat_map(|b| &b.entities)); let attribs = entities.clone().filter_map(|e| match &e.kind { EntityKind::Insert(i) => Some(i.attribs.iter().filter_map(|(c, _)| c.handle)), @@ -1154,7 +1494,9 @@ impl Drawing<'_> { .filter_map(|e| e.common.handle) .chain(attribs.flatten()) .chain(tables.flatten()) - .chain(self.dim_styles.iter().filter_map(|d| d.handle)); + .chain(self.dim_styles.iter().filter_map(|d| d.handle)) + .chain(self.mleader_styles.iter().filter_map(|(_, s)| s.handle)) + .chain(self.table_styles.iter().filter_map(|(_, s)| s.handle)); let mut ids = Ids::new(given.chain(self.root_entries.iter().map(|(_, h)| *h))); // Table records first, so entities numbered after them never take a handle a record asks for. let layer_h: Vec = self.layers.iter().map(|l| ids.take(l.handle)).collect(); @@ -1162,9 +1504,16 @@ impl Drawing<'_> { let style_h: Vec = self.styles.iter().map(|s| ids.take(s.handle)).collect(); let dim_h: Vec = self.dim_styles.iter().map(|d| ids.take(d.handle)).collect(); let block_h: Vec<[u64; 3]> = self.blocks.iter().map(|_| [ids.take(None), ids.take(None), ids.take(None)]).collect(); + // The style dictionaries and their styles, in the versions that have them. + let styles = |on: bool, handles: Vec>, ids: &mut Ids| { + (on && !handles.is_empty()).then(|| (ids.take(None), handles.into_iter().map(|h| ids.take(h)).collect::>())) + }; + let mleader_h = styles(v >= Format::R2007, self.mleader_styles.iter().map(|(_, s)| s.handle).collect(), &mut ids); + let table_h = styles(v >= Format::R2004, self.table_styles.iter().map(|(_, s)| s.handle).collect(), &mut ids); // The content first: the header's handle seed comes after every handle. - let mut body = W { v, t: Vec::new() }; + let names = self.blocks.iter().zip(&block_h).map(|(b, h)| (b.name.to_ascii_uppercase(), h[0])); + let mut body = W { v, t: Vec::new(), blocks: names.collect() }; let mut skipped = 0usize; if v.handles() { body.section("BLOCKS"); @@ -1196,12 +1545,26 @@ impl Drawing<'_> { body.t.extend(self.raw_entities.iter().cloned()); body.end_section(); if v.handles() { - self.objects(&mut body); + self.objects(&mut body, mleader_h.as_ref(), table_h.as_ref()); } self.header(&mut w, ids.take(None)); if v.handles() { - self.classes(&mut w); + let all = || self.entities.iter().chain(self.blocks.iter().flat_map(|b| &b.entities)); + let mut classes = Vec::new(); + if table_h.is_some() { + classes.push(("TABLESTYLE", "AcDbTableStyle", 4095, false)); + } + if mleader_h.is_some() { + classes.push(("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false)); + } + if v >= Format::R2004 && all().any(|e| matches!(e.kind, EntityKind::Table(_))) { + classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); + } + if v >= Format::R2007 && all().any(|e| matches!(e.kind, EntityKind::MLeader(_))) { + classes.push(("MULTILEADER", "AcDbMLeader", 1025, true)); + } + self.classes(&mut w, &classes); } w.section("TABLES"); self.vport(&mut w); @@ -1279,7 +1642,9 @@ impl Drawing<'_> { w.end_section(); } - fn classes(&self, w: &mut W) { + /// The CLASSES section: the classes every R2000 file has, then `extra` (name, C++ class, + /// proxy flags, is an entity), then the caller's. + fn classes(&self, w: &mut W, extra: &[(&str, &str, i64, bool)]) { w.section("CLASSES"); if self.format.r2000() { for (name, cpp) in @@ -1297,6 +1662,18 @@ impl Drawing<'_> { w.i(281, 0); } } + for (name, cpp, flags, entity) in extra { + w.s(0, "CLASS"); + w.s(1, *name); + w.s(2, *cpp); + w.s(3, "ObjectDBX Classes"); + w.i(90, *flags); + if self.format.true_color() { + w.i(91, 0); + } + w.i(280, 0); + w.i(281, i64::from(*entity)); + } w.t.extend(self.raw_classes.iter().cloned()); w.end_section(); } @@ -1493,7 +1870,7 @@ impl Drawing<'_> { w.end_table(); } - fn objects(&self, w: &mut W) { + fn objects(&self, w: &mut W, mleader_h: Option<&(u64, Vec)>, table_h: Option<&(u64, Vec)>) { let v = self.format; w.section("OBJECTS"); let dict = |w: &mut W, h: u64, owner: u64, entries: &[(&str, u64)]| { @@ -1508,6 +1885,12 @@ impl Drawing<'_> { if v.r2000() { root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]); } + if let Some((d, _)) = mleader_h { + root.push(("ACAD_MLEADERSTYLE", *d)); + } + if let Some((d, _)) = table_h { + root.push(("ACAD_TABLESTYLE", *d)); + } root.extend(self.root_entries.iter().map(|(n, h)| (n.as_str(), *h))); dict(w, fixed::ROOT, 0, &root); dict(w, fixed::GROUPS, fixed::ROOT, &[]); @@ -1526,6 +1909,20 @@ impl Drawing<'_> { self.layout(w, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD); self.layout(w, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD); } + if let Some((d, handles)) = mleader_h { + let entries: Vec<(&str, u64)> = self.mleader_styles.iter().map(|(n, _)| n.as_str()).zip(handles.iter().copied()).collect(); + dict(w, *d, fixed::ROOT, &entries); + for ((_, s), h) in self.mleader_styles.iter().zip(handles) { + w.mleader_style(s, *h, *d); + } + } + if let Some((d, handles)) = table_h { + let entries: Vec<(&str, u64)> = self.table_styles.iter().map(|(n, _)| n.as_str()).zip(handles.iter().copied()).collect(); + dict(w, *d, fixed::ROOT, &entries); + for ((_, s), h) in self.table_styles.iter().zip(handles) { + w.table_style(s, *h, *d); + } + } w.t.extend(self.raw_objects.iter().cloned()); w.end_section(); } diff --git a/crates/dxf/tests/model_mleader.rs b/crates/dxf/tests/model_mleader.rs index a5a5c45a3..6a64e4e4b 100644 --- a/crates/dxf/tests/model_mleader.rs +++ b/crates/dxf/tests/model_mleader.rs @@ -1,6 +1,7 @@ //! MULTILEADER and MLEADERSTYLE. use craft_dxf::model::mleader::{MAX_ROOTS, MAX_VERTICES, MLeader, MLeaderStyle}; +use craft_dxf::model::write::{Drawing, Format}; use craft_dxf::model::{Document, Entity, EntityKind}; use craft_dxf::{Tag, parse}; @@ -109,9 +110,11 @@ fn a_multileader_style() { } #[test] -fn the_writer_leaves_a_multileader_out() { +fn the_writer_leaves_a_multileader_out_before_2007() { let t = tags(MLEADER); let e = Entity::read(&craft_dxf::model::entity_records(&craft_dxf::model::records(&t)).0[0]); - let (_, skipped) = craft_dxf::model::write::Drawing { entities: vec![e], ..Default::default() }.tags(); - assert_eq!(skipped, 1); + for (format, left_out) in [(Format::R2004, 1), (Format::R2007, 0)] { + let (_, skipped) = Drawing { format, entities: vec![e.clone()], ..Default::default() }.tags(); + assert_eq!(skipped, left_out, "{format:?}"); + } } diff --git a/crates/dxf/tests/model_table.rs b/crates/dxf/tests/model_table.rs index fa4d5b6e9..c07fec7dd 100644 --- a/crates/dxf/tests/model_table.rs +++ b/crates/dxf/tests/model_table.rs @@ -1,6 +1,7 @@ //! ACAD_TABLE and TABLESTYLE. use craft_dxf::model::table::{MAX_SIDE, Table, TableStyle}; +use craft_dxf::model::write::{Drawing, Format}; use craft_dxf::model::{Color, Document, Entity, EntityKind, Lineweight, entity_records, records}; use craft_dxf::{Tag, parse}; @@ -93,9 +94,11 @@ fn a_table_style() { } #[test] -fn the_writer_leaves_a_table_out() { +fn the_writer_leaves_a_table_out_before_2004() { 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); + for (format, left_out) in [(Format::R2000, 1), (Format::R2004, 0)] { + let (_, skipped) = Drawing { format, entities: vec![e.clone()], ..Default::default() }.tags(); + assert_eq!(skipped, left_out, "{format:?}"); + } } diff --git a/crates/dxf/tests/model_write_annotations.rs b/crates/dxf/tests/model_write_annotations.rs new file mode 100644 index 000000000..fff313115 --- /dev/null +++ b/crates/dxf/tests/model_write_annotations.rs @@ -0,0 +1,260 @@ +//! MULTILEADER, ACAD_TABLE and their styles through the writer: what the model reads from a +//! file, written as ASCII DXF and read again, is what it was. + +use craft_dxf::model::mleader::{LeaderRoot, MLeader, MLeaderStyle}; +use craft_dxf::model::table::{Border, CellTypeStyle, Table, TableCell, TableStyle}; +use craft_dxf::model::write::{BlockDef, Drawing, Format}; +use craft_dxf::model::{Color, Common, Document, Entity, EntityKind, Lineweight, Transparency}; +use craft_dxf::{Tag, parse, write_ascii_as}; + +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") +} + +fn common() -> Common { + Common { + handle: None, + owner: None, + layer: "Notes".into(), + linetype: None, + color: Color::ByLayer, + lineweight: Lineweight::ByLayer, + ltscale: 1.0, + visible: true, + paper_space: false, + transparency: Transparency::ByLayer, + thickness: 0.0, + extrusion: [0.0, 0.0, 1.0], + plot_style: None, + } +} + +/// `d` written as ASCII text in its format and read back. +fn through_text(d: &Drawing) -> Vec { + let (t, skipped) = d.tags(); + assert_eq!(skipped, 0); + parse(write_ascii_as(&t, d.format.utf8()).as_bytes()).expect("the written file parses") +} + +/// What #986's reader takes from a multileader with every value it has. +fn mleader() -> MLeader { + MLeader { + style: Some(0x60), + leader_type: Some(2), + dogleg_on: Some(true), + dogleg: Some(0.36), + arrow_size: Some(0.18), + content_type: Some(2), + text_style: Some(0x61), + block: Some(0x62), + block_scale: Some([2.0, 2.0, 1.0]), + block_rotation: Some(0.5), + left_attachment: Some(1), + right_attachment: Some(3), + attribs: vec![(0xAD, "value one".into()), (0xAE, "value two".into())], + base: Some([5.0, 5.0, 0.5]), + text_height: Some(2.5), + context_arrow_size: Some(0.2), + landing_gap: Some(0.09), + has_text: Some(true), + text: Some("first\nsecond ^ caret".into()), + context_text_style: Some(0x61), + text_location: Some([6.0, 5.0, 0.0]), + text_direction: Some([1.0, 0.0, 0.0]), + text_rotation: Some(0.25), + text_width: Some(20.0), + line_spacing: Some(1.25), + line_spacing_style: Some(2), + text_alignment: Some(3), + context_left_attachment: Some(1), + context_right_attachment: Some(2), + has_block: Some(false), + context_block: Some(0x62), + block_location: Some([7.0, 8.0, 0.0]), + context_block_scale: Some([2.0, 2.0, 1.0]), + context_block_rotation: Some(0.5), + roots: vec![ + LeaderRoot { + landing: Some([4.0, 5.0, 0.0]), + direction: Some([1.0, 0.0, 0.0]), + dogleg: Some(0.36), + lines: vec![vec![[0.0, 0.0, 0.0], [2.0, 3.0, 1.0]], vec![[0.0, 9.0, 0.0]]], + }, + LeaderRoot { landing: Some([8.0, 5.0, 0.0]), direction: Some([-1.0, 0.0, 0.0]), dogleg: Some(0.5), lines: vec![] }, + ], + } +} + +fn mleader_style() -> MLeaderStyle { + MLeaderStyle { + handle: Some(0x60), + description: "Notes".into(), + content_type: 2, + leader_type: 2, + landing_gap: 0.09, + dogleg: 0.36, + arrow_size: 0.18, + text_height: 2.5, + text_style: Some(0x61), + block: Some(0x62), + annotative: true, + } +} + +fn table() -> Table { + let cell = |text: &str, span: (u32, u32), merged: bool| TableCell { + cell_type: 1, + text: text.into(), + span: Some(span), + merged, + text_height: Some(2.5), + alignment: Some(5), + text_style: Some("Bold".into()), + block: None, + }; + Table { + block: Some("*T1".into()), + style: Some(0x70), + insert: [1.0, 2.0, 0.0], + direction: [1.0, 0.0, 0.0], + rows: 2, + columns: 2, + row_heights: vec![0.5, 0.7], + column_widths: vec![3.0, 4.0], + cells: vec![ + cell("Title", (1, 2), false), + cell("", (0, 0), true), + // Longer than one string group: it goes out in pieces and comes back whole. + cell(&"long ".repeat(80), (1, 1), false), + TableCell { cell_type: 2, block: Some(0x62), ..cell("", (1, 1), false) }, + ], + } +} + +fn table_style() -> TableStyle { + let border = |k: i64| Border { + lineweight: Some(Lineweight::Hundredths(u16::try_from(k * 5).unwrap_or(0))), + visible: Some(k % 2 == 0), + color: Some(Color::Index(u8::try_from(k + 1).unwrap_or(1))), + }; + let cell_type = |style: &str, fill: Option| CellTypeStyle { + text_style: style.into(), + text_height: Some(0.18), + alignment: Some(5), + text_color: Some(Color::ByBlock), + fill, + borders: [0, 1, 2, 3, 4, 5].map(border), + }; + TableStyle { + handle: Some(0x70), + description: "Plain".into(), + flow: 1, + margin_h: Some(0.06), + margin_v: Some(0.07), + title_suppressed: false, + header_suppressed: true, + cell_types: vec![cell_type("Standard", Some(Color::Index(7))), cell_type("Bold", None), cell_type("Title", Some(Color::Index(3)))], + } +} + +fn drawing(format: Format) -> Drawing<'static> { + let e = |kind| Entity { common: common(), kind }; + Drawing { + format, + blocks: vec![BlockDef { name: "*T1".into(), ..BlockDef::default() }], + entities: vec![e(EntityKind::MLeader(mleader())), e(EntityKind::Table(table()))], + mleader_styles: vec![("Notes".into(), mleader_style())], + table_styles: vec![("Plain".into(), table_style())], + ..Drawing::default() + } +} + +#[test] +fn a_multileader_and_a_table_read_back_as_they_were_written() { + for format in [Format::R2007, Format::R2018] { + let t = through_text(&drawing(format)); + let d = Document::read(&t); + let back: Vec = d.entities.iter().map(|e| Entity::read(e).kind).collect(); + assert_eq!(back, [EntityKind::MLeader(mleader()), EntityKind::Table(table())], "{format:?}"); + // The table names its block's record, as AutoCAD reads it. + let names = d.block_names(); + let record = d.entities[1].groups().handle(343); + assert_eq!(record.and_then(|h| names.get(&h).copied()), Some("*T1")); + } +} + +#[test] +fn the_styles_read_back_in_their_dictionaries() { + let t = through_text(&drawing(Format::R2018)); + let d = Document::read(&t); + let root = d.root_dictionary().expect("the root dictionary"); + let names = d.dictionary_names(); + let mleader_dict = d.dictionary(root.get("ACAD_MLEADERSTYLE").expect("the multileader styles")).expect("a dictionary"); + let table_dict = d.dictionary(root.get("ACAD_TABLESTYLE").expect("the table styles")).expect("a dictionary"); + assert_eq!((mleader_dict.get("Notes"), table_dict.get("Plain")), (Some(0x60), Some(0x70)), "given handles are kept"); + let ms = MLeaderStyle::read(d.objects_of("MLEADERSTYLE").next().expect("the style")); + let ts = TableStyle::read(d.objects_of("TABLESTYLE").next().expect("the style")); + assert_eq!((ms, ts), (mleader_style(), table_style())); + assert_eq!((names.get(&0x60).copied(), names.get(&0x70).copied()), (Some("Notes"), Some("Plain"))); + let classes: Vec = d.classes.iter().filter_map(|c| c.groups().s(1)).collect(); + for c in ["MULTILEADER", "MLEADERSTYLE", "ACAD_TABLE", "TABLESTYLE"] { + assert!(classes.iter().any(|x| x == c), "{c} is declared"); + } +} + +#[test] +fn versions_without_them_leave_them_out() { + // 2004: tables and table styles, no multileaders or their styles. + let (t, skipped) = drawing(Format::R2004).tags(); + assert_eq!(skipped, 1); + let d = Document::read(&t); + assert_eq!(d.entities.iter().map(|e| e.kind).collect::>(), ["ACAD_TABLE"]); + assert!(d.objects_of("MLEADERSTYLE").next().is_none() && d.objects_of("TABLESTYLE").next().is_some()); + assert!(d.classes.iter().all(|c| c.groups().s(1).as_deref() != Some("MULTILEADER"))); + // 2000: neither. + let (t, skipped) = drawing(Format::R2000).tags(); + assert_eq!(skipped, 2); + let d = Document::read(&t); + assert!( + d.entities.is_empty() + && d.objects_of("TABLESTYLE").next().is_none() + && d.root_dictionary().is_some_and(|r| r.get("ACAD_TABLESTYLE").is_none()) + ); +} + +/// A multileader and a table as #986's and #994's tests read them from files written elsewhere. +const FROM_FILES: &str = "0|SECTION|2|ENTITIES +0|MULTILEADER|5|80|100|AcDbEntity|8|0|100|AcDbMLeader|270|2 +300|CONTEXT_DATA{|40|1.0|10|5.0|20|5.0|30|0.0|41|2.5|140|0.18|145|0.09|174|1|175|1|290|1|304|first^Jsecond|11|0.0|21|0.0|31|1.0|340|11 +12|6.0|22|5.0|32|0.0|13|1.0|23|0.0|33|0.0|42|0.0|43|20.0|45|1.25|170|2|171|1|296|0|110|0.0|120|0.0|130|0.0 +302|LEADER{|290|1|291|1|10|4.0|20|5.0|30|0.0|11|1.0|21|0.0|31|0.0|90|0|40|0.36 +304|LEADER_LINE{|10|0.0|20|0.0|10|2.0|20|3.0|30|1.0|91|0|305|} +304|LEADER_LINE{|10|0.0|20|9.0|305|} +303|} +302|LEADER{|10|8.0|20|5.0|40|0.5|303|} +301|} +340|1A|90|0|170|1|91|0|171|0|290|1|291|1|41|0.36|42|0.18|172|2|343|11|173|1|95|1|174|0|175|0|176|0|178|0|179|1|45|1.0|271|0|272|9|273|9 +330|AD|177|1|44|0.0|302|value one|330|AE|177|2|44|0.0|302|value two +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 +0|ENDSEC|0|EOF"; + +#[test] +fn what_was_read_from_another_file_writes_back_the_same() { + let t = tags(FROM_FILES); + let read: Vec = Document::read(&t).entities.iter().map(Entity::read).collect(); + assert!(matches!(read[0].kind, EntityKind::MLeader(_)) && matches!(read[1].kind, EntityKind::Table(_))); + let d = Drawing { entities: read.clone(), blocks: vec![BlockDef { name: "*T1".into(), ..BlockDef::default() }], ..Drawing::default() }; + let again: Vec = Document::read(&through_text(&d)).entities.iter().map(|e| Entity::read(e).kind).collect(); + let mut want: Vec = read.into_iter().map(|e| e.kind).collect(); + // The third cell's text was in a 3 group, which the writer puts in a 2: it reads the same. + if let Some(EntityKind::Table(tb)) = want.get_mut(1) { + assert_eq!(tb.cells[2].text, "first-part-last"); + } + assert_eq!(again, want); +}