diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index f8a48f2a..b81bd00d 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -541,12 +541,80 @@ mod native { dimension_flags(&mut doc); drop_unwritable(&mut doc); current_dimstyle(&mut doc); + add_variable_dictionary(&mut doc); current_text_style(&mut doc); std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) } + /// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the + /// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive). + const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary"; + + /// A style or scale name, or `default` when the drawing has none. + fn name_or_default(name: &str, default: &str) -> String { + let name = name.trim(); + if name.is_empty() { default.to_string() } else { name.to_string() } + } + + /// A drawing variable's value as text, or `default` when the header holds a value the + /// variable doesn't accept (a hostile file can put any integer in it). + fn variable_value(value: i16, valid: &[i16], default: i16) -> String { + if valid.contains(&value) { value.to_string() } else { default.to_string() } + } + + /// Add the drawing variables dictionary AutoCAD always writes into the named object + /// dictionary, with the variables it keeps there, when the drawing has none (#1813). + /// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported + /// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such + /// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs + /// read there without it, so newer output is left as it was, and a dictionary the drawing + /// does have keeps its own entries and values. + fn add_variable_dictionary(doc: &mut acadrust::CadDocument) { + use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType}; + if doc.version >= acadrust::DxfVersion::AC1032 { + return; + } + let root_handle = doc.header.named_objects_dict_handle; + match doc.objects.get(&root_handle) { + Some(ObjectType::Dictionary(root)) => { + if root.get(VARIABLE_DICTIONARY).is_some() { + return; + } + } + _ => return, + } + // The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation + // scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and + // LAYERNOTIFY have no DXF header variable. + let variables = [ + ("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")), + ("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")), + ("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")), + ("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)), + ("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)), + ("LAYEREVAL", "0".to_string()), + ("LAYERNOTIFY", "0".to_string()), + ]; + let dictionary_handle = doc.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = dictionary_handle; + dictionary.owner = root_handle; + for (name, value) in variables { + let handle = doc.allocate_handle(); + let mut variable = DictionaryVariable::new(name, &value); + variable.handle = handle; + variable.owner_handle = dictionary_handle; + dictionary.add_entry(name, handle); + doc.objects.insert(handle, ObjectType::DictionaryVariable(variable)); + } + doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) { + root.add_entry(VARIABLE_DICTIONARY, dictionary_handle); + } + } + /// Entities of a layout other than the one in `*Paper_Space` are written owned by their block /// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1, /// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a diff --git a/crates/io/tests/dwg_required_dictionaries.rs b/crates/io/tests/dwg_required_dictionaries.rs new file mode 100644 index 00000000..3feb0d7d --- /dev/null +++ b/crates/io/tests/dwg_required_dictionaries.rs @@ -0,0 +1,147 @@ +//! #1813: AutoCAD always writes an `AcDbVariableDictionary` in the named object dictionary; +//! ezdxf, and CADCraft's own DXF writer, do not. Vectric Aspire 10.5 reads an AC1018 DWG without +//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an +//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables, +//! when the DXF has none. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::Cursor; + +use cadcraft_doc::{Common, Drawing, EntityKind, LwPolyline, Space}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{SaveOptions, Version, write_with}; + +/// ezdxf's rectangle: one closed LWPOLYLINE in model space. +fn rectangle() -> Drawing { + let mut d = Drawing::new_metric(); + let corners = [(0.0, 0.0), (4.0, 0.0), (4.0, 2.0), (0.0, 2.0)]; + let pl = LwPolyline { + vertices: corners.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(), + closed: true, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(pl)).expect("add the rectangle"); + d +} + +/// The drawing variables in the named object dictionary, with their values, or nothing when the +/// dictionary is missing. Dictionary keys are case-insensitive in AutoCAD; its own spelling is +/// `AcDbVariableDictionary` (the DXF Reference calls it the ACDBVARIABLEDICTIONARY). +fn variable_dictionary(doc: &acadrust::CadDocument) -> Option> { + let acadrust::objects::ObjectType::Dictionary(root) = doc.objects.get(&doc.header.named_objects_dict_handle)? else { + return None; + }; + let acadrust::objects::ObjectType::Dictionary(vars) = doc.objects.get(&root.get("AcDbVariableDictionary")?)? else { + return None; + }; + Some( + vars.entries + .iter() + .filter_map(|(name, handle)| match doc.objects.get(handle) { + Some(acadrust::objects::ObjectType::DictionaryVariable(v)) => Some((name.clone(), v.value.clone())), + _ => None, + }) + .collect(), + ) +} + +/// Read a DWG back with acadrust's reader, so the assertions inspect the file as another +/// application would rather than through CADCraft's own importer. +fn read_dwg(dwg: &[u8]) -> acadrust::CadDocument { + acadrust::DwgReader::from_stream(Cursor::new(dwg)).read().expect("read the DWG back") +} + +/// The value of the variable called `name`, matched case-insensitively as AutoCAD matches +/// dictionary keys, or nothing when the dictionary has no such entry. +fn value<'a>(vars: &'a [(String, String)], name: &str) -> Option<&'a str> { + vars.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) +} + +/// #1813's regression: a DXF written without an `AcDbVariableDictionary` (as ezdxf and CADCraft +/// write them) must gain AutoCAD's dictionary when exported as an AC1018 DWG, so readers that +/// require it see the drawing's contents; the added objects must not break CADCraft's own read. +#[test] +fn an_ac1018_dwg_gets_the_variable_dictionary_autocad_always_writes() { + let d = rectangle(); + let dxf = write_with(&d, "rect.dxf", &SaveOptions { version: Some(Version::R2000), binary: false }).expect("write the DXF"); + let text = String::from_utf8_lossy(&dxf).to_ascii_lowercase(); + assert!(!text.contains("acdbvariabledictionary"), "this DXF is one without the dictionary, like ezdxf's"); + + let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2004), binary: false }).expect("write the DWG"); + assert!(dwg.starts_with(b"AC1018"), "the DWG is AutoCAD 2004"); + let doc = read_dwg(&dwg); + let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary"); + + // The variables AutoCAD keeps there, by name (Aspire needs at least the dictionary; these + // are the ones the reference application writes). + for (name, want) in [("CMLEADERSTYLE", "Standard"), ("CTABLESTYLE", "Standard"), ("CANNOSCALE", "1:1"), ("DIMASSOC", "2"), ("HIDETEXT", "1")] { + assert_eq!(value(&vars, name), Some(want), "{name} in {vars:?}"); + } + assert!(value(&vars, "LAYEREVAL").is_some() && value(&vars, "LAYERNOTIFY").is_some(), "{vars:?}"); + + // The added objects don't disturb CADCraft's own read of the file: the rectangle is there. + let back = cadcraft_io::read(&dwg, "rect.dwg").expect("CADCraft reads its own 2004 DWG"); + assert_eq!(back.model.len(), 1); +} + +/// The dictionary must carry the drawing's own `$DIMASSOC` and `$HIDETEXT`, not AutoCAD's +/// defaults: a drawing that turned text hiding off or stores dimensions non-associatively keeps +/// those settings for every consumer that reads them from the dictionary. +#[test] +fn the_dictionary_keeps_the_drawings_dimassoc_and_hidetext() { + let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n1\n9\n$HIDETEXT\n70\n0\n0\nENDSEC\n0\nEOF\n"; + let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG"); + let doc = read_dwg(&dwg); + let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary"); + assert_eq!(value(&vars, "DIMASSOC"), Some("1"), "DIMASSOC in {vars:?}"); + assert_eq!(value(&vars, "HIDETEXT"), Some("0"), "HIDETEXT in {vars:?}"); + // The DWG's own header settings match the dictionary, so both kinds of consumer agree. + assert_eq!(doc.header.dimension_associativity, 1); + assert_eq!(doc.header.hide_text, 0); +} + +/// A header variable holding a value the variable doesn't accept is written with AutoCAD's +/// default, as a hostile file can put any integer there. +#[test] +fn impossible_header_values_fall_back_to_the_defaults() { + let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DIMASSOC\n70\n9\n9\n$HIDETEXT\n70\n7\n0\nENDSEC\n0\nEOF\n"; + let dwg = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1018").expect("write the DWG"); + let doc = read_dwg(&dwg); + let vars = variable_dictionary(&doc).expect("AC1018 DWG: the named object dictionary has an AcDbVariableDictionary"); + assert_eq!(value(&vars, "DIMASSOC"), Some("2"), "DIMASSOC in {vars:?}"); + assert_eq!(value(&vars, "HIDETEXT"), Some("1"), "HIDETEXT in {vars:?}"); +} + +/// The AC1032 (AutoCAD 2018) path is deliberately unchanged: those DWGs read in Vectric Aspire +/// without the dictionary, so no dictionary is injected and the existing output stays as it was. +#[test] +fn an_ac1032_dwg_stays_as_it_was() { + // 2018 DWGs read fine in Aspire without the dictionary, so the latest format is unchanged. + let d = rectangle(); + let dwg = write_with(&d, "rect.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("write the DWG"); + assert!(dwg.starts_with(b"AC1032")); + let doc = read_dwg(&dwg); + assert_eq!(variable_dictionary(&doc), None, "no dictionary is added to a 2018 DWG"); +} + +/// A drawing that already has the dictionary must keep its own entries and values: conversion +/// only supplies AutoCAD's dictionary when it is absent, rather than merging defaults into one +/// that a producer such as ezdxf's `add_dict_var` already wrote. +#[test] +fn a_variable_dictionary_the_drawing_has_is_left_alone() { + // A drawing that already has the dictionary keeps its own values and entries (here only + // CMLEADERSTYLE, as ezdxf's add_dict_var("CMLEADERSTYLE", "Standard") writes it): a DWG + // export must not add defaults next to them. + let mut source = acadrust::CadDocument::new(); + source.version = acadrust::DxfVersion::AC1015; + assert!(source.set_drawing_variable("CMLEADERSTYLE", "Notes")); + let dxf = acadrust::DxfWriter::new(&source).write_to_vec().expect("write a DXF with the dictionary"); + + let dwg = cadcraft_dwg::dxf_to_dwg_as(&dxf, "AC1018").expect("write the DWG"); + let doc = read_dwg(&dwg); + let vars = variable_dictionary(&doc).expect("the drawing's dictionary is kept"); + assert_eq!(vars.len(), 1, "{vars:?}"); + assert_eq!(value(&vars, "CMLEADERSTYLE"), Some("Notes"), "{vars:?}"); +}