From 3f9e45f7262b2998ec541524fc165e84e2da8589 Mon Sep 17 00:00:00 2001 From: mkzung <103102868+mkzung@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:32:34 +0000 Subject: [PATCH] DWG: another application's objects are kept as proxy objects of their class An object of another application's class that acadrust has no model for is read only as its DWG record (ObjectType::Unknown), which its DXF writer leaves out. The DWG to DXF bridge lost every such object with no note, and the references to it went too (an extension dictionary entry naming the object, say). Each one is now carried as a proxy object of its class (ACAD_PROXY_OBJECT): the record is split into its common object data (owner, reactors, extension dictionary) and the streams after it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg describes them. The references are read to the end of the handle stream (to its exact length from R2010 on), null ones too: a null reference is an empty field, and the references after it belong to the next fields. Objects of the drawing database's own classes, and records that don't split, stay as they were and are named in a note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H43qQA41CJLauqXnPgBQ7r --- crates/dwg/src/lib.rs | 331 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 0dcbd0667..5a7eabbac 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -107,6 +107,9 @@ mod native { } let mut notes = read_notes(&outcome.stats); let mut doc = outcome.document; + notes.extend( + guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?, + ); let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?; if orphans > 0 { let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; @@ -390,6 +393,214 @@ mod native { std::panic::catch_unwind(f).map_err(|_| ()) } + /// The version a proxy record of a DWG of `version` names its format by (Open Design + /// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004, + /// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version. + fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option { + Some(match version.version_code() { + 1012 => 19, + 1014 => 21, + 1015 => 23, + 1018 => 25, + 1021 => 27, + 1024 => 29, + 1027 => 31, + 1032 => 33, + _ => return None, + }) + } + + /// Objects of another application's class that acadrust keeps only as their DWG record + /// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the + /// references to them went with them (a dimension's extension dictionary entry naming its + /// application data, say), with no note. Each is carried as a proxy object of its class instead + /// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it + /// again: its common object data (owner, reactors, extension dictionary) and the streams after + /// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg + /// splits an object record. Objects of the drawing database's own classes are not turned into + /// another application's proxies. What is still without a DXF form is named in the notes, so a + /// save over the original is not mistaken for a complete one. + fn application_objects(doc: &mut acadrust::CadDocument) -> Vec { + use acadrust::objects::{ObjectType, ProxyObject}; + let classes: Vec = doc.classes.iter().cloned().collect(); + let source = doc.dwg_source_version.unwrap_or(doc.version); + if let Some(dwg_version) = proxy_dwg_version(source) { + let maintenance = i32::from(doc.maintenance_version); + for object in doc.objects.values_mut() { + let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } = + &*object + else { + continue; + }; + if !application_class(&classes, type_name) { + continue; + } + let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version }; + let handle = *handle; + // A record that doesn't split stays as it was, and is named below. + let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue }; + let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue }; + *object = ObjectType::ProxyObject(ProxyObject { + handle, + owner: record.owner, + reactors: record.reactors, + xdictionary_handle: record.xdictionary, + proxy_id: 499, + class_id: i32::from(class.class_number), + dwg_version, + maintenance_version: maintenance, + version: (maintenance << 16) | dwg_version, + payload: record.payload, + text_payload: record.text_payload, + object_ids: record.object_ids, + ..Default::default() + }); + } + } + let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for object in doc.objects.values() { + if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object { + let n = left.entry(type_name.as_str()).or_insert(0); + *n = n.saturating_add(1); + } + } + if left.is_empty() { + return Vec::new(); + } + let total = left.values().fold(0usize, |a, n| a.saturating_add(*n)); + let mut list: Vec = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect(); + if left.len() > NOTE_TYPES { + list.push(format!("{} more types", left.len() - NOTE_TYPES)); + } + vec![format!( + "DWG: {total} objects of types CADCraft doesn't keep were left out ({}); saving over the original file would lose them", + list.join(", ") + )] + } + + /// Whether `name` is an object class of another application, which only that application + /// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose + /// leftovers the database regenerates itself. + fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool { + classes.iter().any(|c| { + !c.is_an_entity + && c.dxf_name.eq_ignore_ascii_case(name) + && !c.cpp_class_name.starts_with("AcDb") + && !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p)) + }) + } + + /// An application object acadrust kept only as its record: the parts a proxy object carries. + struct ObjectRecord { + kind: i16, + owner: acadrust::types::Handle, + reactors: Vec, + xdictionary: Option, + payload: acadrust::objects::ProxyPayload, + text_payload: acadrust::objects::ProxyPayload, + object_ids: Vec, + } + + /// Split an object record into its common object data and the uninterpreted streams after it + /// (data bits, R2007+ strings and object references), as an application object's proxy holds + /// them (Open Design Specification for .dwg, common object data and proxy objects). The record + /// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest. + fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result { + use acadrust::io::dwg::{ + DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader, + dwg_stream_writers::bit_writer::DwgBitWriter, + }; + use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind}; + use acadrust::types::{DxfVersion, Handle}; + if raw.data.len() > 32 << 20 { + return Err("object record exceeds the transport limit".into()); + } + let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?; + let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?; + if handle_bits > total_bits { + return Err("object handle stream exceeds its record".into()); + } + let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?; + let mut frame = DwgBitWriter::new(version, raw.version); + frame.write_modular_short(raw.data.len()); + if raw.version >= DxfVersion::AC1024 { + frame.write_modular_char(handle_bits); + } + frame.write_bytes(&raw.data); + let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, std::collections::HashMap::new()).map_err(|e| e.to_string())?; + let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?; + if kind < 500 { + return Err("object has no application class".into()); + } + let common = reader.read_common_non_entity_data(&mut streams, kind); + streams.check_error().map_err(|e| e.to_string())?; + if common.common.handle != handle { + return Err("object record names another handle".into()); + } + let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result { + if count > total_bits { + return Err("object stream exceeds its record".into()); + } + let n = u32::try_from(count).map_err(|_| "object stream is too large")?; + let mut bytes = vec![0u8; count.div_ceil(8)]; + for index in 0..count { + if bit() + && let Some(b) = bytes.get_mut(index / 8) + { + *b |= 0x80 >> (index % 8); + } + } + Ok(ProxyPayload::from_bits(&bytes, n)) + }; + let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?; + let payload = read_bits(main, &mut || streams.read_bit())?; + let text_payload = if raw.version >= DxfVersion::AC1021 { + let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?; + read_bits(bits, &mut || streams.read_text_bit())? + } else { + ProxyPayload::default() + }; + // Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its + // field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+ + // records give the stream's exact length; the bits of its last byte past it are padding. + let mut object_ids = Vec::new(); + let mut left = if raw.version >= DxfVersion::AC1024 { + streams.get_handle_bits().max(0).min(streams.handle_remaining_bits()) + } else { + streams.handle_remaining_bits() + }; + while left >= 8 { + if object_ids.len() >= 100_000 { + return Err("object has too many references".into()); + } + let before = streams.handle_remaining_bits(); + let (target, reference) = streams.read_handle_reference(handle); + let used = before.saturating_sub(streams.handle_remaining_bits()); + if used <= 0 || used > left { + break; + } + left -= used; + let kind = match reference { + DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership, + DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership, + DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer, + DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer, + DwgReferenceType::Undefined => ProxyReferenceKind::Undefined, + }; + object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind }); + } + streams.check_error().map_err(|e| e.to_string())?; + Ok(ObjectRecord { + kind, + owner: Handle::from(common.owner_handle), + reactors: common.reactors.into_iter().map(Handle::from).collect(), + xdictionary: common.xdictionary_handle.map(Handle::from), + payload, + text_payload, + object_ids, + }) + } + /// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is /// left unmerged. Returns the cells visited. fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize { @@ -1159,6 +1370,126 @@ mod native { assert_eq!(guarded(|| 3), Ok(3)); } } + + #[cfg(test)] + mod application_object_tests { + use super::*; + use acadrust::CadDocument; + use acadrust::classes::DxfClass; + use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject}; + use acadrust::types::{DxfVersion, Handle}; + + /// An object of another application's class that acadrust keeps only as its record (a + /// vertical product's dimension data, say) is carried as that class's proxy object, with its + /// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice. + #[test] + fn undecoded_application_object_is_kept_as_its_proxy() { + for version in [DxfVersion::AC1027, DxfVersion::AC1032] { + let mut source = CadDocument::new(); + source.version = version; + source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer")); + let handle = Handle::from(0x2Bu64); + let target = Handle::from(0x2Cu64); + let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18); + // A null pointer between two references: it keeps its place, the one after it is not lost. + let references = vec![ + ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer }, + ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer }, + ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer }, + ]; + source.objects.insert( + handle, + ObjectType::RegisteredClass(RegisteredClassObject { + handle, + owner: Handle::from(0x0Cu64), + dxf_name: "TESTCONTAINER".into(), + cpp_class_name: "xyzDbTestContainer".into(), + payload: payload.clone(), + object_ids: references.clone(), + ..Default::default() + }), + ); + let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap(); + // acadrust has no model of it: read back, it is only its record. + let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap(); + assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle)); + for generation in 0..2 { + let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap(); + assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}"); + // The DXF has the object, by its handle, as a proxy record. + let text = String::from_utf8_lossy(&dxf); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]); + assert!(record, "{version:?} {generation}: no proxy record for the object"); + dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap(); + let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap(); + let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") }; + assert_eq!(proxy.payload.bit_count, payload.bit_count); + assert_eq!(proxy.payload.data(), payload.data()); + assert_eq!(proxy.object_ids, references); + let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap(); + assert_eq!(class.dxf_name, "TESTCONTAINER"); + assert!(!class.is_an_entity); + } + } + } + + /// The database's own classes are not turned into another application's proxies; what is + /// left without a DXF form is said, not dropped silently. + #[test] + fn database_objects_without_a_form_are_reported() { + let mut doc = CadDocument::new(); + doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable")); + let handle = Handle::from(0x40u64); + doc.objects.insert( + handle, + ObjectType::Unknown { + type_name: "BLOCKPROPERTIESTABLE".into(), + handle, + owner: Handle::from(0x0Cu64), + raw_dxf_codes: None, + raw_dwg_data: Some(vec![0; 8]), + raw_dwg_handle_bits: 0, + raw_dwg_version: Some(DxfVersion::AC1032), + }, + ); + let notes = application_objects(&mut doc); + assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. }))); + assert_eq!( + notes, + [ + "DWG: 1 objects of types CADCraft doesn't keep were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them" + ] + ); + } + + /// A record that doesn't split stays as it was and is named, never guessed at. + #[test] + fn invalid_object_records_are_rejected() { + for handle_bits in [-1, 1000] { + let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 }; + assert!(object_record(&raw, 42).is_err()); + } + let mut doc = CadDocument::new(); + doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer")); + let handle = Handle::from(0x41u64); + doc.objects.insert( + handle, + ObjectType::Unknown { + type_name: "TESTCONTAINER".into(), + handle, + owner: Handle::from(0x0Cu64), + raw_dxf_codes: None, + raw_dwg_data: Some(vec![0xFF; 4]), + raw_dwg_handle_bits: 1000, + raw_dwg_version: Some(DxfVersion::AC1032), + }, + ); + let notes = application_objects(&mut doc); + assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. }))); + assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}"); + } + } } #[cfg(not(no_dwg_bridge))]