From 228f6d0c50f1fdee4c5124603602993fc79d4c9d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 22:12:08 -0500 Subject: [PATCH] craft-dxf: write MULTILEADER, ACAD_TABLE, MLEADERSTYLE and TABLESTYLE (#1011) * craft-dxf: model, the drawing above the tags (header, tables, blocks, layouts) Document::read indexes a tag list into header variables, table records, blocks with their entities, the ENTITIES records (a POLYLINE with its vertices, an INSERT with its attributes) and the objects, each a slice of the tag list. The typed table records, LAYOUT and DICTIONARY are made on request. Also the colour index palette, colour, lineweight and transparency values, extended data lookup and the object coordinate system. Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's vectorcraft-cad import (566f7208). Neither app's document types appear. * craft-dxf: model entities (geometry, text, MTEXT columns, hatches, dimensions) Entity::read types an entity record: the properties every entity has, and LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE, POLYLINE with its vertices (2D, 3D, mesh, polyface), SPLINE, RAY, XLINE, TEXT, MTEXT with columns, ATTDEF, INSERT with its attributes (multiline ones included), DIMENSION, LEADER, SOLID, TRACE, 3DFACE, HATCH (polyline and edge loops, pattern lines, seeds, gradient), VIEWPORT, IMAGE, WIPEOUT and TOLERANCE. Other types stay readable from the record's tags. model::text has the caret encoding of strings, both ways. Merged from CADCraft's cadcraft-io dxf_read (5a6aa9b6) and VectorCraft's vectorcraft-cad import (566f7208). * craft-dxf: a hatch spline edge without fit data leaves the loop's source count Before R2010 a spline edge has no 97 of its own, so the 97 after it counts the loop's source entities. It was read as the edge's fit point count and the sources were lost. * craft-dxf: model::write, a drawing as tags (R12 to 2018) Drawing::tags writes the header, classes, the nine symbol tables, blocks, entities and (R2000 and later) the dictionaries, plot style placeholder and layouts around the layers, linetypes, styles, dimension styles, blocks and entities it is given, in the model's own types. entity_tags writes one entity for a caller with its own file. Handles given with the content are kept; the rest are numbered after them. The file skeleton is VectorCraft's vectorcraft-cad writer.rs (566f7208); the MTEXT column groups are CADCraft's dxf_read/mtext_dxf.rs column_tags (5a6aa9b6). * craft-dxf: model objects (page setup, groups, image definitions, draw order, clip filters, DIMASSOC, scales, multiline styles), HELIX and dimension overrides model::objects types the OBJECTS records both readers and CADCraft's passes use: the AcDbPlotSettings groups of a LAYOUT, GROUP, IMAGEDEF, SORTENTSTABLE, SPATIAL_FILTER, DIMASSOC, SCALE and MLINESTYLE, and dim_overrides reads the DSTYLE list of a dimension's ACAD extended data. Document gains objects_of and dictionary_names (the name a dictionary lists an object under). HELIX is an entity kind, read and written. From CADCraft's cadcraft-io dxf_read/objects.rs, document.rs, dimassoc.rs, entities.rs, dxf_mline.rs, dxf_clip.rs and dxf_image.rs (5a6aa9b6). * craft-dxf: an attribute's vertical alignment is group 74, not the field length in 73 Group 73 of an ATTRIB or ATTDEF is its field length. A writer that emits it turned every attribute into another vertical alignment, since 73 was read first and 74 only in its absence. Ported from storytold/cadcraft#1591. * README: keep main's crate list after the merge * craft-dxf: read entity groups written after the entity's own subclass marker Some writers leave the AcDbEntity subclass without a layer and put its groups right after the entity's own marker. Every entity has a layer, so its absence marks that layout, and the run of AcDbEntity codes that starts after the marker is read too. The run ends at the first other code, so the entity's own groups that reuse a code (an MTEXT's 48) are not taken for it. Ported from storytold/cadcraft#2877. * craft-dxf: an attribute's justification is read from its text groups, not its multiline header A multiline ATTRIB or ATTDEF writes a header in its AcDbAttribute subclass whose 72 and 11 are not the justification and alignment point. A left-justified attribute writes no 72 of its own, so the header's was read and the attribute came out centred. The text is now read from the groups up to the attribute's subclass and, after it, from all but 71, 72, 73 and 11. Ported from storytold/cadcraft#2313. * craft-dxf: MLINE, read and written EntityKind::MLine holds the style name and handle, scale, justification, flags, start point and each vertex with its direction, miter and the 41 and 42 parameter lists of each element. Reading the 41s and 42s into the list their 74 or 75 started keeps them apart. From CADCraft's cadcraft-io dxf_mline.rs read_entity and entity_tags (5a6aa9b6). * craft-dxf: MULTILEADER and MLEADERSTYLE, read model::mleader reads a MULTILEADER by level (the entity's groups, its CONTEXT_DATA block, its leader roots and their leader lines, which reuse the same codes) into MLeader, keeping the entity's and the context's copies of a value apart, and MLEADERSTYLE into MLeaderStyle. Roots, lines, vertices and attribute values are capped as CADCraft caps them. The writer leaves a multileader out and counts it, as it does any record it has no groups for. From CADCraft's cadcraft-io dxf_mleader.rs parse (5a6aa9b6). * craft-dxf: ACAD_TABLE and TABLESTYLE, read model::table reads an ACAD_TABLE's size, row heights, column widths and its cells (text, merge span, merged flag, text height outside the value block, alignment, text style, block) into Table, and a TABLESTYLE's margins, suppressed rows and its data, header and title cell types with their six borders into TableStyle. A size past 10,000 a side or 1,000,000 cells, or with no rows or columns, keeps nothing, and only cells the file holds are kept. The writer leaves a table out and counts it. From CADCraft's cadcraft-io dxf_read/entities.rs acad_table and dxf_table.rs read_table_style (5a6aa9b6). * craft-dxf: renumber the handles of records kept from another file model::write::remap_handles rewrites a run of records' own handles and every pointer group (is_pointer_code: 320-369, 390-399, 480-481, 1005) through a map, writing the null handle for anything the map drops, so records a caller keeps from a file can go into raw_entities and raw_objects without colliding with what the writer numbers. From CADCraft's cadcraft-io dxf_raw.rs (5a6aa9b6), which renumbers what it keeps this way. * craft-dxf: write MULTILEADER, ACAD_TABLE, MLEADERSTYLE and TABLESTYLE model::write now writes the multileaders and tables the model reads: a MULTILEADER (2007 and later) with its context block, roots and leader lines and its entity overrides, and an ACAD_TABLE (2004 and later) with its size, row heights, column widths and cells, its group 343 naming the record of its block. Drawing gains mleader_styles and table_styles, written in the ACAD_MLEADERSTYLE and ACAD_TABLESTYLE dictionaries, and the CLASSES of all four. Groups and fixed values follow CADCraft's writer, which AutoCAD reads. MLeader also reads the context's landing gap (145) now, so it round-trips. From CADCraft's cadcraft-io dxf_mleader.rs entity_tags, dxf_write/entity.rs write_table_entity, dxf_write/objects.rs mleader_style_obj and table_style_obj, dxf_table.rs cell_type_tags and dxf_write/sections.rs write_classes (5a6aa9b6). --------- Co-authored-by: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Francesco BV --- crates/dxf/src/model/entity.rs | 4 +- crates/dxf/src/model/mleader.rs | 3 + crates/dxf/src/model/write.rs | 427 +++++++++++++++++++- crates/dxf/tests/model_mleader.rs | 9 +- crates/dxf/tests/model_table.rs | 9 +- crates/dxf/tests/model_write_annotations.rs | 260 ++++++++++++ 6 files changed, 689 insertions(+), 23 deletions(-) create mode 100644 crates/dxf/tests/model_write_annotations.rs 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); +}