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
@@ -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"))]new file mode 100644@@ -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(())+}@@ -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 |