From 2fa967db65708e07b8979e2fd5b019b2b49cb9e2 Mon Sep 17 00:00:00 2001 From: Muhi <122024641+The-Great-Nothing@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:53:33 +0200 Subject: [PATCH] fix(io): write DXF CLASS instance counts and preserve current leader style (#635) Squash of PR #635 by @The-Great-Nothing. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/dxf_ext.rs | 5 ++- crates/io/src/dxf_read.rs | 3 ++ crates/io/src/dxf_write.rs | 43 ++++++++++++++++++--- crates/io/tests/roundtrip_audit.rs | 15 +++++++ crates/io/tests/tables_objects_roundtrip.rs | 6 ++- 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 73c9c6e..f3fb0f6 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -6,7 +6,8 @@ //! //! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the //! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES`, -//! `CADCRAFT_PLOTSTYLES` and `CADCRAFT_HANDSEED` (XRECORDs). Other readers keep or ignore them. +//! `CADCRAFT_PLOTSTYLES`, `CADCRAFT_HANDSEED` and `CADCRAFT_MLEADERSTYLE` (XRECORDs). Other +//! readers keep or ignore them. use std::collections::HashMap; @@ -32,6 +33,8 @@ pub(crate) const ANNOTATION_KEY: &str = "CADCRAFT_ANNOTATION"; pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME"; /// Named-object-dictionary key of the XRECORD holding the drawing's next entity handle (hex). pub(crate) const HANDSEED_KEY: &str = "CADCRAFT_HANDSEED"; +/// Named-object-dictionary key of the XRECORD holding the current multileader style. +pub(crate) const MLEADER_STYLE_KEY: &str = "CADCRAFT_MLEADERSTYLE"; /// Most plot style tables and plot style names read from a file. pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000; pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000; diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 2dbca97..57a4d3f 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1843,6 +1843,9 @@ impl Objects { } fn apply(self, d: &mut Drawing, rx: &Rx) { + if let Some(style) = self.xrecord_text(crate::dxf_ext::MLEADER_STYLE_KEY).filter(|s| !s.trim().is_empty()) { + d.header.set_str("CMLEADERSTYLE", &style); + } // Table styles (named by their ACAD_TABLESTYLE dictionary entries). for (h, tags) in &self.table_styles { let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index ab9537e..dd03ba8 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1555,6 +1555,9 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { // The drawing's own next handle: table records and objects are numbered above it, so // reading it back (instead of $HANDSEED) keeps their handles across saves (#495). xrecords.push((dxf_ext::HANDSEED_KEY, w.h(), vec![format!("{:X}", d.handseed)])); + if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) { + xrecords.push((dxf_ext::MLEADER_STYLE_KEY, w.h(), vec![name.to_string()])); + } if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) { xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks)); } @@ -1601,11 +1604,6 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { w.p(10, ext.max.to3(0.0)); } header_vars(&mut w, d); - // The current multileader style (an R2007 variable; older readers skip it). - if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) { - w.s(9, "$CMLEADERSTYLE"); - w.s(2, name); - } let seed_pos = w.t.len(); w.s(9, "$HANDSEED"); w.s(5, "0"); @@ -2255,10 +2253,45 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { *t = Tag::s(5, seed); } crate::dxf_raw::finish(&mut w.t, d); + add_class_instance_counts(&mut w.t); mark_xrefs(&mut w.t, d); w.t } +/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` +/// restores foreign classes and objects so every class gets its actual instance count. +fn add_class_instance_counts(tags: &mut Vec) { + let mut counts: HashMap = HashMap::new(); + for t in tags.iter().filter(|t| t.code == 0) { + let kind = t.str(); + if kind != "CLASS" { + *counts.entry(kind.to_ascii_uppercase()).or_default() += 1; + } + } + + let mut start = 0; + while start < tags.len() { + if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") { + start += 1; + continue; + } + let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i); + let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase(); + let count = counts.get(&name).copied().unwrap_or(0); + let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) { + tags[start + 1 + i] = Tag::i(91, count); + false + } else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) { + tags.insert(start + 2 + i, Tag::i(91, count)); + true + } else { + false + }; + // Inserting the group shifts the next record's start by one. + start = end + usize::from(inserted); + } +} + /// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in /// group 1, set on the written BLOCK records of blocks that have an `xref_path`. fn mark_xrefs(tags: &mut [Tag], d: &Drawing) { diff --git a/crates/io/tests/roundtrip_audit.rs b/crates/io/tests/roundtrip_audit.rs index 6a9fa07..10f910a 100644 --- a/crates/io/tests/roundtrip_audit.rs +++ b/crates/io/tests/roundtrip_audit.rs @@ -878,6 +878,21 @@ fn saving_twice_writes_the_same_records() { assert_eq!(a.counts(), b.counts()); } +#[test] +fn class_records_have_instance_counts_for_generated_and_preserved_records() { + let (_, a, b) = saves(&fixture()); + for out in [&a, &b] { + let counts = out.counts(); + for class in out.in_section("CLASSES", "CLASS") { + let name = s(class, 1); + let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64); + assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}"); + } + assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT")); + assert_eq!(counts.get("ACME_OBJECT"), Some(&1)); + } +} + #[test] fn erased_entities_take_their_extension_data_with_them() { let mut d = read_dxf(fixture().as_bytes()).expect("read"); diff --git a/crates/io/tests/tables_objects_roundtrip.rs b/crates/io/tests/tables_objects_roundtrip.rs index 1bedd4e..0f75e4c 100644 --- a/crates/io/tests/tables_objects_roundtrip.rs +++ b/crates/io/tests/tables_objects_roundtrip.rs @@ -73,7 +73,11 @@ fn multileader_styles_and_current_style_round_trip() { }; d.mleader_styles.push(callout); d.header.set_str("CMLEADERSTYLE", "Callout"); - let r = round_trip(&d); + let text = write_dxf(&d); + let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF"); + let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER"); + assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE")); + let r = read_dxf(text.as_bytes()).expect("reopen"); assert_eq!(r.mleader_styles, d.mleader_styles); assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout"); }