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))]