collective

Browse / github.com/storytold/cadcraft / patch

DWG: keep dynamic block purge preventers through a save

DWG: keep dynamic block purge preventers through a save Part of FF1 / gaps.md top-10 #1 ("Saving DWG drops what we don't model"): one of the losses `cadcraft-cli audit` (PR 4) shows on real drawings. **What** acadrust 0.6.5's DXF writer leaves `ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION` objects (and their dictionary entries) out of the DXF the DWG bridge reads, though its DXF reader and DWG writer handle them. Every DWG saved by CADCraft lost them; without them AutoCAD's PURGE may remove a dynamic block's representations after a round trip. `crates/dwg`: the import puts each one back into the DXF text before CADCraft reads it, with its entry in its

Status
open
Score
0.00
Adopters
0 other operators
Author
adirbiton (imported from pull request #2095, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+281 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
d6334b335256aedc748537a4

How it went

No other operator has reported using it yet.

Diff

raw · crates/dwg/src/lib.rs crates/io/tests/dwg_purge_preventer.rs docs/file-format-parity.md

diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs--- a/crates/dwg/src/lib.rs+++ b/crates/dwg/src/lib.rs@@ -124,7 +124,7 @@ mod native {             ));         }         written.map_err(|e| format!("DWG→DXF: {e}"))?;-        Ok((add_header_vars(out.buf, &doc.header), notes))+        Ok((add_purge_preventers(add_header_vars(out.buf, &doc.header), &doc, limits.max_dxf_bytes), notes))     }      /// Most record types named in the skipped-records note.@@ -432,6 +432,139 @@ mod native {         dxf     } +    /// Dynamic block purge preventers (`ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION`, in a dynamic+    /// block record's extension dictionary) into the DXF. acadrust 0.6.5's DXF writer leaves them+    /// out, entry and object, though its DXF reader and DWG writer handle them; without them a DWG+    /// saved by CADCraft lets AutoCAD's PURGE remove a dynamic block's representations after a+    /// round trip through an older version. Each goes back (groups after the DXF Reference's+    /// common object groups: 70 version, 340 block record) with its entry in its dictionary, which+    /// must be in the text without xdata; one whose handle is already there is left alone. A name that+    /// isn't a plain DXF name, or a key with a line break, is left out, and so is what would take the+    /// text past `limit` bytes. The OBJECTS section is indexed once.+    fn add_purge_preventers(mut dxf: Vec<u8>, doc: &acadrust::CadDocument, limit: usize) -> Vec<u8> {+        use acadrust::objects::{DynamicBlockData, ObjectType};+        let preventer = |name: &str| {+            let plain = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');+            let n: String = name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).collect();+            plain && n.starts_with("ACDBDYNAMICBLOCKPURGEPREVENTER")+        };+        let mut found: Vec<(acadrust::Handle, &str, &acadrust::objects::DynamicBlockObject)> = Vec::new();+        for (h, o) in &doc.objects {+            let ObjectType::Dictionary(d) = o else { continue };+            for (key, e) in &d.entries {+                if let Some(ObjectType::DynamicBlock(p)) = doc.objects.get(e)+                    && preventer(&p.dxf_name)+                    && !key.contains(['\r', '\n'])+                    && p.owner == *h+                {+                    found.push((*h, key.as_str(), p));+                }+            }+        }+        if found.is_empty() {+            return dxf;+        }+        let find = |dxf: &[u8], pat: &[u8], from: usize| dxf.get(from..).and_then(|t| t.windows(pat.len()).position(|w| w == pat)).map(|p| p + from);+        // The section's own header: a layer or block named OBJECTS has a group 2 of that value too.+        let Some(objects) = find(&dxf, b"  0\r\nSECTION\r\n  2\r\nOBJECTS\r\n", 0) else { return dxf };+        let Some(objects_end) = find(&dxf, b"  0\r\nENDSEC\r\n", objects) else { return dxf };+        let index = ObjectIndex::of(&dxf, objects, objects_end);+        // (where, groups): dictionary entries, then the objects at the section's end.+        let mut inserts: Vec<(usize, String)> = Vec::new();+        let mut records = String::new();+        let mut added = 0usize;+        let hex = |h: acadrust::Handle| format!("{:X}", h.value());+        for (dict, key, p) in found {+            let DynamicBlockData::Representation(r) = &p.data else { continue };+            if index.handles.contains(&hex(p.handle)) {+                continue;+            }+            let Some(&(end, false)) = index.dictionaries.get(&hex(dict)) else { continue };+            let entry = format!("  3\r\n{key}\r\n360\r\n{}\r\n", hex(p.handle));+            let reactors: String = p.reactors.iter().map(|h| format!("330\r\n{}\r\n", hex(*h))).collect();+            let reactors = if reactors.is_empty() { String::new() } else { format!("102\r\n{{ACAD_REACTORS\r\n{reactors}102\r\n}}\r\n") };+            let record = format!(+                "  0\r\n{name}\r\n  5\r\n{h}\r\n{reactors}330\r\n{owner}\r\n100\r\nAcDbDynamicBlockPurgePreventer\r\n 70\r\n{flags}\r\n340\r\n{block}\r\n",+                name = p.dxf_name,+                h = hex(p.handle),+                owner = hex(p.owner),+                flags = r.flags,+                block = hex(r.block),+            );+            let grown = added.saturating_add(entry.len()).saturating_add(record.len());+            if dxf.len().saturating_add(grown) > limit {+                break;+            }+            added = grown;+            inserts.push((end, entry));+            records.push_str(&record);+        }+        // First among insertions at the same place (stable sort; one spliced later goes before it):+        // an entry for the section's last dictionary stays in it, ahead of the new objects.+        inserts.insert(0, (objects_end, records));+        inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at));+        for (at, text) in inserts {+            dxf.splice(at..at, text.into_bytes());+        }+        dxf+    }++    /// The records of an ASCII DXF's OBJECTS section, read once: every record's own handle, and+    /// for each DICTIONARY where its record ends and whether it carries xdata (upper-case hex).+    struct ObjectIndex {+        handles: std::collections::HashSet<String>,+        dictionaries: std::collections::HashMap<String, (usize, bool)>,+    }++    impl ObjectIndex {+        /// Index the records between `from` and `to` (the section's `ENDSEC` record).+        fn of(dxf: &[u8], from: usize, to: usize) -> ObjectIndex {+            let mut index = ObjectIndex { handles: Default::default(), dictionaries: Default::default() };+            // The record being read: (kind, own handle, has xdata).+            let mut open: Option<(String, Option<String>, bool)> = None;+            let close = |open: Option<(String, Option<String>, bool)>, end: usize, index: &mut ObjectIndex| {+                if let Some((kind, Some(h), xdata)) = open {+                    if kind == "DICTIONARY" {+                        index.dictionaries.entry(h.clone()).or_insert((end, xdata));+                    }+                    index.handles.insert(h);+                }+            };+            let body = dxf.get(from..to).unwrap_or(&[]);+            let mut at = 0;+            let line = |at: usize| -> Option<(&[u8], usize)> {+                let rest = body.get(at..)?;+                let n = rest.windows(2).position(|w| w == b"\r\n")?;+                Some((rest.get(..n)?, at + n + 2))+            };+            while let Some((code, next)) = line(at)+                && let Some((value, after)) = line(next)+            {+                let value = String::from_utf8_lossy(value).trim().to_ascii_uppercase();+                match String::from_utf8_lossy(code).trim() {+                    "0" => {+                        close(open.take(), from + at, &mut index);+                        open = Some((value, None, false));+                    }+                    "5" => {+                        if let Some((_, h @ None, _)) = &mut open {+                            *h = Some(value);+                        }+                    }+                    "1001" => {+                        if let Some((_, _, x)) = &mut open {+                            *x = true;+                        }+                    }+                    _ => {}+                }+                at = after;+            }+            close(open, to, &mut index);+            index+        }+    }+     /// Collects the DXF text and fails the write once it would grow past `limit`.     struct CappedWriter {         buf: Vec<u8>,@@ -639,6 +772,78 @@ mod native {             }         }     }+    #[cfg(test)]+    mod purge_preventer_tests {+        use acadrust::objects::{BlockRepresentationData, DynamicBlockData, DynamicBlockObject, ObjectType};+        use acadrust::{CadDocument, DxfVersion};++        const NAME: &str = "ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION";++        /// A document whose model space extension dictionary holds a purge preventer named `name`,+        /// and acadrust's DXF of it (which leaves the preventer out).+        fn source(name: &str) -> Result<(CadDocument, Vec<u8>), Box<dyn std::error::Error>> {+            let mut doc = CadDocument::with_version(DxfVersion::AC1032);+            let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;+            let xd = doc.ensure_extension_dictionary(ms);+            let h = doc.allocate_handle();+            let mut p = DynamicBlockObject::new(name, "AcDbDynamicBlockPurgePreventer");+            p.handle = h;+            p.owner = xd;+            p.reactors = vec![xd];+            p.data = DynamicBlockData::Representation(BlockRepresentationData { flags: 1, block: ms });+            doc.objects.insert(h, ObjectType::DynamicBlock(p));+            let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };+            d.entries.push(("AcDbDynamicBlockRoundTripPurgePreventer".into(), h));+            let mut dxf = Vec::new();+            acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf)?;+            Ok((doc, dxf))+        }++        /// How many times `pat` occurs in `dxf`.+        fn count(dxf: &[u8], pat: &str) -> usize {+            dxf.windows(pat.len()).filter(|w| *w == pat.as_bytes()).count()+        }++        /// A preventer goes back into the DXF once; a second pass leaves the text alone.+        #[test]+        fn a_preventer_goes_back_once() -> Result<(), Box<dyn std::error::Error>> {+            let (doc, dxf) = source(NAME)?;+            assert_eq!(count(&dxf, &format!("  0\r\n{NAME}\r\n")), 0, "acadrust's DXF leaves it out");+            let once = super::add_purge_preventers(dxf, &doc, usize::MAX);+            assert_eq!(count(&once, &format!("  0\r\n{NAME}\r\n")), 1);+            assert_eq!(count(&once, "AcDbDynamicBlockRoundTripPurgePreventer"), 1);+            // Already there: left alone.+            let twice = super::add_purge_preventers(once.clone(), &doc, usize::MAX);+            assert_eq!(twice, once);+            Ok(())+        }++        /// A layer named OBJECTS (its group 2 comes before the OBJECTS section's) doesn't take the+        /// restored records: they go into the OBJECTS section.+        #[test]+        fn a_layer_named_objects_is_not_the_objects_section() -> Result<(), Box<dyn std::error::Error>> {+            let (mut doc, _) = source(NAME)?;+            doc.layers.add(acadrust::tables::Layer::new("OBJECTS"))?;+            let mut dxf = Vec::new();+            acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf)?;+            let out = super::add_purge_preventers(dxf, &doc, usize::MAX);+            let text = String::from_utf8_lossy(&out);+            let section = text.find("  0\r\nSECTION\r\n  2\r\nOBJECTS\r\n").ok_or("OBJECTS section")?;+            let record = text.find(&format!("  0\r\n{NAME}\r\n")).ok_or("restored record")?;+            assert!(record > section, "the record is in the OBJECTS section");+            Ok(())+        }++        /// A name that would break the DXF structure, or a record past the size limit, is left out.+        #[test]+        fn a_name_with_a_line_break_or_past_the_limit_is_left_out() -> Result<(), Box<dyn std::error::Error>> {+            let (doc, dxf) = source(&format!("{NAME}\r\n  0\r\nLINE"))?;+            assert_eq!(super::add_purge_preventers(dxf.clone(), &doc, usize::MAX), dxf);+            let (doc, dxf) = source(NAME)?;+            assert_eq!(super::add_purge_preventers(dxf.clone(), &doc, dxf.len()), dxf);+            Ok(())+        }+    } }  #[cfg(not(target_arch = "wasm32"))]diff --git a/crates/io/tests/dwg_purge_preventer.rs b/crates/io/tests/dwg_purge_preventer.rsnew file mode 100644--- /dev/null+++ b/crates/io/tests/dwg_purge_preventer.rs@@ -0,0 +1,68 @@+//! Dynamic block purge preventers (FF1): acadrust 0.6.5's DXF writer leaves them out of the DXF+//! CADCraft reads, so the DWG import puts them back; a DWG save then keeps them, in their block+//! record's extension dictionary. Original synthetic source; the DWG oracle shares acadrust 0.6.5,+//! no AutoCAD validation is claimed.+#![cfg(not(target_arch = "wasm32"))]++use acadrust::classes::DxfClass;+use acadrust::entities::Line;+use acadrust::objects::{BlockRepresentationData, DynamicBlockData, DynamicBlockObject, ObjectType};+use acadrust::{CadDocument, DwgWriter, DxfVersion, EntityType};++type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;++const NAME: &str = "ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION";+const KEY: &str = "AcDbDynamicBlockRoundTripPurgePreventer";++/// AC1032 with a line and model space's extension dictionary holding a purge preventer.+fn source() -> TestResult<Vec<u8>> {+    let mut doc = CadDocument::with_version(DxfVersion::AC1032);+    doc.add_entity(EntityType::Line(Line::from_coords(0., 0., 0., 10., 0., 0.)))?;+    let mut class = DxfClass::new(NAME, "AcDbDynamicBlockPurgePreventer");+    class.application_name = "ObjectDBX Classes".into();+    doc.classes.add_or_update(class);+    let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;+    let xd = doc.ensure_extension_dictionary(ms);+    let h = doc.allocate_handle();+    let mut p = DynamicBlockObject::new(NAME, "AcDbDynamicBlockPurgePreventer");+    p.handle = h;+    p.owner = xd;+    p.reactors = vec![xd];+    p.data = DynamicBlockData::Representation(BlockRepresentationData { flags: 1, block: ms });+    doc.objects.insert(h, ObjectType::DynamicBlock(p));+    let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&xd) else { return Err("extension dictionary".into()) };+    d.entries.push((KEY.into(), h));+    Ok(DwgWriter::write_to_vec(&doc)?)+}++/// The purge preventer in model space's extension dictionary: (flags, whether it names model space).+fn preventer(bytes: &[u8]) -> TestResult<Option<(i16, bool)>> {+    let out = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read_with_stats()?;+    assert_eq!((out.stats.skipped_source_records, out.stats.recovered_errors), (0, 0));+    let doc = out.document;+    let ms = doc.block_records.get("*Model_Space").ok_or("model space")?.handle;+    let Some(xd) = doc.extension_dictionary_handle(ms) else { return Ok(None) };+    let Some(ObjectType::Dictionary(d)) = doc.objects.get(&xd) else { return Ok(None) };+    let Some((_, h)) = d.entries.iter().find(|(k, _)| k == KEY) else { return Ok(None) };+    Ok(match doc.objects.get(h) {+        Some(ObjectType::DynamicBlock(DynamicBlockObject { dxf_name, owner, data: DynamicBlockData::Representation(r), .. }))+            if dxf_name == NAME && *owner == xd =>+        {+            Some((r.flags, r.block == ms))+        }+        _ => None,+    })+}++/// The preventer (flags and block) survives two DWG saves through CADCraft.+#[test]+fn a_purge_preventer_survives_two_dwg_saves() -> TestResult {+    let mut bytes = source()?;+    assert_eq!(preventer(&bytes)?, Some((1, true)));+    for _ in 0..2 {+        let d = cadcraft_io::read(&bytes, "in.dwg")?;+        bytes = cadcraft_io::write_with(&d, "out.dwg", &cadcraft_io::SaveOptions::default())?;+        assert_eq!(preventer(&bytes)?, Some((1, true)));+    }+    Ok(())+}diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (dynamic block purge preventers kept through DWG) · **Target:** Autodesk AutoCAD 2027  Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds@@ -55,7 +55,11 @@ Everything lost in the DXF read/write paths below is also lost through DWG, plus report #57: a 10 MB DWG failed after about two minutes; limits now stop runaway conversions early (#175), but the throughput problem remains. DGN line style components that no line style definition uses (leftovers of DGN imports, hundreds of thousands in some drawings) are left out-on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG ≈ 30% read,+on read with a note, so such files no longer exceed the DXF limit (#1482). Dynamic block purge+preventers (`ACDB_DYNAMICBLOCKPURGEPREVENTER_VERSION`, in a dynamic block record's extension+dictionary), which acadrust 0.6.5's DXF output leaves out, are put back into the DXF CADCraft+reads, so a DWG save keeps them (`dwg_purge_preventer`); without them AutoCAD's PURGE may remove+a dynamic block's representations. **DWG ≈ 30% read, ≈ 15% write.**  ## DXF, version by version@@ -140,6 +144,7 @@ our output in AutoCAD black-box.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | DWG: dynamic block purge preventers, left out of acadrust's DXF output, are put back on read, so DWG saves keep them | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |