diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 82a1934d..5f497bc7 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -185,7 +185,7 @@ fn export_framed(s: &Session, out: &str, window: Option) -> Result<(), let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?; std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({} bytes)", bytes.len()); - if !cadcraft_io::is_image(out) { + if cadcraft_engine::cmd::file::drops_unknown(out) { let lost = cadcraft_engine::cmd::file::not_saved(d); if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) { eprintln!("{m}"); diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs new file mode 100644 index 00000000..e1b3acc1 --- /dev/null +++ b/crates/doc/src/foreign.rs @@ -0,0 +1,79 @@ +//! Data from a drawing file that CADCraft does not model, kept so that saving the drawing writes +//! it back: other programs' objects, extension dictionaries, xdata and properties (#528). +//! +//! It is kept as the file's own DXF groups, with the file's handles. The file reader fills it and +//! the writer puts it back, pointing references at whatever now has the referenced role (the +//! named object dictionary, a table record, a block, an entity); references to objects that are +//! gone are dropped. Commands don't edit it. + +use std::collections::BTreeMap; + +use crate::{Handle, RawTag}; + +/// What a file handle stood for when the object it named is one CADCraft writes itself (under a +/// new handle on save): a kind and an upper-case name, e.g. `("LAYER", "WALLS")`, +/// `("TABLE", "LAYER")`, `("BLOCK", "DOOR")`, `("NOD", "")` for the named object dictionary and +/// `("NOD", "ACAD_GROUP")` for one of its entries. +pub type ForeignKey = (String, String); + +/// A non-graphical object of a type or role CADCraft does not model. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ForeignObject { + /// Its handle in the file it came from (kept on save). + pub handle: Handle, + /// Its DXF type (group 0), e.g. `XRECORD`. + pub kind: String, + /// Its groups after the type, as read. + pub tags: Vec, +} + +/// What an entity, table record or block carries beyond what CADCraft models. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ForeignExtra { + /// Its extension dictionary (one of [`Foreign::objects`]). + pub xdict: Option, + /// Objects of [`Foreign::objects`] that react to it (persistent reactors). + pub reactors: Vec, + /// Other applications' extended data: groups from `1001` on. + pub xdata: Vec, + /// The colour book colour (group 430) and the true colour (group 420) it was read with: it is + /// written back while the colour is unchanged. + pub color_book: Option<(String, String)>, + /// Other unmodelled property groups (material 347, shadow mode 284). + pub props: Vec, +} + +impl ForeignExtra { + pub fn is_empty(&self) -> bool { + self.xdict.is_none() && self.reactors.is_empty() && self.xdata.is_empty() && self.color_book.is_none() && self.props.is_empty() + } +} + +/// Everything of a file CADCraft keeps without modelling it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Foreign { + /// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types. + pub classes: Vec>, + /// Kept objects, in file order. + pub objects: Vec, + /// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object). + pub dictionary: Vec<(String, Handle)>, + /// Extras of entities, by handle. + pub entities: BTreeMap, + /// Extras of table records (`("LAYER", "WALLS")`), tables (`("TABLE", "LAYER")`) and blocks + /// (`("BLOCK", "DOOR")`), by [`ForeignKey`]. + pub records: BTreeMap, + /// The roles of the file handles the kept data refers to, for objects CADCraft rewrites. + pub targets: BTreeMap, +} + +impl Foreign { + pub fn is_empty(&self) -> bool { + self.objects.is_empty() + && self.dictionary.is_empty() + && self.entities.is_empty() + && self.records.is_empty() + && self.classes.is_empty() + && self.targets.is_empty() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ee7bdca0..b8a1592c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -31,6 +31,9 @@ pub use plotstyle::*; pub use store::EntityStore; pub use tables::*; +/// File data CADCraft doesn't model, kept for saving (#528). +pub mod foreign; + #[derive(Debug, thiserror::Error)] pub enum DocError { #[error("no such layer `{0}`")] @@ -57,6 +60,8 @@ pub enum Space { #[derive(Clone, Debug, PartialEq)] pub struct Drawing { pub header: Header, + /// File data CADCraft does not model, written back on save. + pub foreign: std::sync::Arc, pub layers: Vec, pub linetypes: Vec, pub text_styles: Vec, @@ -115,6 +120,7 @@ impl Drawing { fn bare() -> Self { Drawing { header: Header::default(), + foreign: std::sync::Arc::default(), layers: vec![Layer::default(), Layer { name: "Defpoints".into(), plot: false, ..Layer::default() }], linetypes: vec![ Linetype { name: "ByBlock".into(), description: String::new(), pattern: Vec::new() }, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 33b960d3..5f5ed12f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -328,7 +328,7 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result { let lost = not_saved(s.doc()?); let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?; let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }); - if is_drawing_file(&format!("drawing.{fmt}")) { + if drops_unknown(&format!("drawing.{fmt}")) { report_not_saved(s, &mut r, &lost); } Ok(r) @@ -355,7 +355,13 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Op return None; } let list: Vec = lost.iter().map(|(t, n)| format!("{n} {t}")).collect(); - Some(format!("Not saved (CADCraft can't write these object types yet): {}", list.join(", "))) + Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", "))) +} + +/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG +/// bridge drops types it doesn't know); DXF writes them back as read. +pub fn drops_unknown(path: &str) -> bool { + std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("dwg")) } /// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image. @@ -377,7 +383,7 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result) -> Result { let lost = s.doc().map(not_saved).unwrap_or_default(); let mut r = run(s, p)?; - if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) { + if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) { report_not_saved(s, &mut r, &lost); } Ok(r) diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 7525a0b6..12b81ad7 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -1,5 +1,6 @@ //! Saving a drawing with entities of types CADCraft doesn't model (read from another program's -//! file) says which ones the DXF/DWG file doesn't keep, instead of dropping them silently. +//! file) as DWG says which ones the file doesn't keep, instead of dropping them silently. DXF +//! writes them back as read: nothing to report. use cadcraft_engine::Session; use cadcraft_engine::cmd::file::{IoHooks, set_io}; @@ -24,13 +25,20 @@ fn save_lists_the_entities_the_file_does_not_keep() { let mut s = Session::empty(); s.open_drawing(d, "foreign.dxf", None); - let path = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.dxf", std::process::id())); + let path = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.dwg", std::process::id())); let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); let _ = std::fs::remove_file(&path); assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 })); - let msg = "Not saved (CADCraft can't write these object types yet): 1 3DSOLID, 2 MLINE, 1 REGION"; + let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 3DSOLID, 2 MLINE, 1 REGION"; assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log); + // DXF keeps them. + let dxf = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.dxf", std::process::id())); + let n = s.log.len(); + let r = s.execute("saveas", &json!({ "path": dxf.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&dxf); + assert!(r.get("notSaved").is_none() && s.log.len() == n); + // Exporting an image isn't a save of the drawing: no report. let png = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.png", std::process::id())); let r = s.execute("saveas", &json!({ "path": png.to_string_lossy() })).unwrap(); diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs new file mode 100644 index 00000000..ee49bc9c --- /dev/null +++ b/crates/io/src/dxf_raw.rs @@ -0,0 +1,1004 @@ +//! What a DXF file holds that CADCraft does not model, kept as raw groups so that saving writes +//! it back (#528): entities and objects of other types, other applications' xdata, extension +//! dictionaries, persistent reactors, colour book colours and other unmodelled entity properties. +//! +//! Reading ([`read`]) keeps the data with the file's handles in [`Drawing::foreign`], plus the +//! role of every handle it refers to that CADCraft writes itself (a table record, a block, the +//! named object dictionary…). Writing ([`finish`]) runs over the finished tag list: it places the +//! entities of types CADCraft doesn't model among the written ones (the entity writer leaves them +//! out), puts the data back on the records that still exist and points each reference at what now +//! has the role it named. Kept objects get new handles above the written ones (so they never +//! collide with the handles the writer hands out). A reference to something that is gone is +//! dropped (from reactor lists and dictionaries) or written as the null handle `0`; objects whose +//! owner is gone go with it. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::foreign::{Foreign, ForeignExtra, ForeignKey, ForeignObject}; +use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, RawTag, Unknown}; +use cadcraft_dxf::{Section, Tag}; + +/// Named object dictionary entries CADCraft writes itself from its model (plus every key that +/// starts with `CADCRAFT`): they are read into the model, not kept raw. A change that starts +/// modelling another entry adds it here. (An entry the writer writes is also never written +/// twice: the kept one is left out.) +const MODELLED_KEYS: &[&str] = + &["ACAD_GROUP", "ACAD_LAYOUT", "ACAD_MLEADERSTYLE", "ACAD_PLOTSTYLENAME", "ACAD_TABLESTYLE", "ACAD_IMAGE_DICT", "ACAD_IMAGE_VARS"]; + +/// Extension dictionary entries not kept raw: dimension associativity (CADCraft reads and +/// rewrites it), draw order and annotation scale contexts (written from their own models by the +/// changes that model them; stale copies would contradict the drawing). +const MODELLED_XDICT_KEYS: &[&str] = &["ACAD_DIMASSOC", "ACAD_SORTENTS", "ACDBCONTEXTDATAMANAGER"]; + +/// Applications whose xdata CADCraft reads and writes itself. `ACAD` holds dimension overrides +/// (DSTYLE) on dimensions and dimension styles; elsewhere it is kept. +const OWN_APPS: &[&str] = &["CADCRAFT", "ACADANNOTATIVE", "ACCMTRANSPARENCY", "ACAECLAYERSTANDARD"]; + +/// Most objects kept from one file. +const MAX_OBJECTS: usize = 1_000_000; +/// Most xdata groups kept per record (AutoCAD allows 16 KB of xdata per object). +const MAX_XDATA_GROUPS: usize = 16_384; +/// Longest ownership chain followed. +const MAX_DEPTH: usize = 64; +/// Handles above this are not believed (hostile files); references to them are dropped. +const MAX_HANDLE: u64 = 1 << 48; + +/// Record types that structure a file: never written as an entity of another type. +const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"]; + +fn up(s: &str) -> String { + s.trim().to_ascii_uppercase() +} + +/// A handle as text, normalised (`002a` → `2A`); other text upper-cased. +fn norm(s: &str) -> String { + Handle::parse_hex(s).map_or_else(|| up(s), Handle::hex) +} + +fn raw(t: &Tag) -> RawTag { + RawTag { code: t.code, value: t.str() } +} + +/// A kept group with the value type its code has (so binary output encodes it right); text that +/// doesn't parse as that type stays text. +fn tag(r: &RawTag) -> Tag { + use cadcraft_dxf::{Kind, Value, kind_of}; + let v = r.value.trim(); + let value = match kind_of(r.code) { + Kind::Real => v.parse::().ok().filter(|x| x.is_finite()).map(Value::Real), + Kind::Int16 | Kind::Int32 | Kind::Int64 => v.parse::().ok().map(Value::Int), + Kind::Bool => v.parse::().ok().map(|x| Value::Bool(x != 0)), + Kind::Hex => Some(Value::Hex(v.to_string())), + Kind::Str => None, + }; + Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone()))) +} + +/// A handle value we believe: hexadecimal, not null, not absurdly large. +fn handle(s: &str) -> Option { + Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE) +} + +/// Group codes whose values are handles of other objects (DXF Reference, group code ranges). +fn is_pointer(code: i32) -> bool { + matches!(code, 320..=369 | 390..=399 | 480..=481 | 1005) +} + +/// The records of a run of tags: (type, index of the record's `0` group, groups after it). +fn split(tags: &[Tag]) -> Vec<(String, usize, &[Tag])> { + let mut out = Vec::new(); + let mut open: Option<(String, usize)> = None; + for (i, t) in tags.iter().enumerate() { + if t.code == 0 { + if let Some((k, s)) = open.take() { + out.push((k, s, tags.get(s + 1..i).unwrap_or(&[]))); + } + open = Some((t.str(), i)); + } + } + if let Some((k, s)) = open { + out.push((k, s, tags.get(s + 1..).unwrap_or(&[]))); + } + out +} + +type Recs<'a> = Vec<(String, usize, &'a [Tag])>; + +/// A record's handle, owner, reactors and extension dictionary (groups before its xdata). +#[derive(Default)] +struct Head { + handle: Option, + owner: Option, + reactors: Vec, + xdict: Option, + has_reactors: bool, +} + +fn head(tags: &[Tag]) -> Head { + let mut h = Head::default(); + let mut group: Option = None; + for t in tags.iter().take_while(|t| t.code < 1000) { + match (t.code, group.as_deref()) { + (102, _) => { + let v = t.str(); + group = v.trim().strip_prefix('{').map(str::to_string); + h.has_reactors |= group.as_deref() == Some("ACAD_REACTORS"); + } + (_, Some("ACAD_REACTORS")) => h.reactors.push(norm(&t.str())), + (_, Some("ACAD_XDICTIONARY")) => { + if h.xdict.is_none() { + h.xdict = Some(norm(&t.str())); + } + } + (_, Some(_)) => {} + (5 | 105, None) if h.handle.is_none() => h.handle = Some(norm(&t.str())), + (330, None) if h.owner.is_none() => h.owner = Some(norm(&t.str())), + _ => {} + } + } + h +} + +fn val(tags: &[Tag], code: i32) -> Option { + tags.iter().take_while(|t| t.code < 1000).find(|t| t.code == code).map(Tag::str) +} + +/// A dictionary's entries: (key, upper-case handle). +fn entries(tags: &[Tag]) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut key: Option = None; + for t in tags.iter().take_while(|t| t.code < 1000) { + match t.code { + 3 => key = Some(t.str()), + 350 | 360 => { + if let Some(k) = key.take() { + out.push((k, norm(&t.str()))); + } + } + _ => {} + } + } + out +} + +fn is_dictionary(kind: &str) -> bool { + kind == "DICTIONARY" || kind == "ACDBDICTIONARYWDFLT" +} + +fn modelled_key(key: &str) -> bool { + let k = up(key); + k.starts_with("CADCRAFT") || MODELLED_KEYS.contains(&k.as_str()) +} + +/// The roles of a file's handles (see [`ForeignKey`]): table records and tables by type and +/// name, blocks by name, the named object dictionary, its entries, and their entries. +fn identities(tables: &Recs, blocks: &Recs, objects: &Recs) -> HashMap { + let mut out: HashMap = HashMap::new(); + let mut put = |h: Option, k: ForeignKey| { + if let Some(h) = h { + out.entry(h).or_insert(k); + } + }; + for (kind, _, t) in tables { + let name = up(&val(t, 2).unwrap_or_default()); + if kind == "TABLE" || !name.is_empty() { + put(head(t).handle, (if kind == "TABLE" { "TABLE".into() } else { kind.clone() }, name)); + } + } + let mut block = String::new(); + for (kind, _, t) in blocks { + match kind.as_str() { + "BLOCK" => { + block = up(&val(t, 2).unwrap_or_default()); + put(head(t).handle, ("BLOCK".into(), block.clone())); + } + "ENDBLK" => put(head(t).handle, ("ENDBLK".into(), block.clone())), + _ => {} + } + } + let Some((_, _, root)) = objects.first().filter(|(k, _, _)| is_dictionary(k)) else { return out }; + put(head(root).handle, ("NOD".into(), String::new())); + let by_handle: HashMap = objects.iter().filter_map(|(k, _, t)| Some((head(t).handle?, (k.as_str(), *t)))).rev().collect(); + for (key, h) in entries(root) { + put(Some(h.clone()), ("NOD".into(), up(&key))); + if let Some((k, t)) = by_handle.get(&h) + && is_dictionary(k) + { + for (name, eh) in entries(t) { + put(Some(eh), (format!("NOD/{}", up(&key)), up(&name))); + } + } + } + out +} + +/// Other applications' xdata of a record of type `kind`: whole applications, each with valid +/// groups (codes 1000 and up, balanced `1002` braces), up to [`MAX_XDATA_GROUPS`]. +fn foreign_xdata(kind: &str, tags: &[Tag]) -> Vec { + let Some(start) = tags.iter().position(|t| t.code == 1001) else { return Vec::new() }; + let rest = tags.get(start..).unwrap_or(&[]); + let mut out = Vec::new(); + let mut i = 0; + while let Some(first) = rest.get(i) { + let len = rest.get(i + 1..).map_or(0, |r| r.iter().position(|t| t.code == 1001).unwrap_or(r.len())); + let chunk = rest.get(i..i + 1 + len).unwrap_or(&[]); + i += 1 + len; + let app = up(&first.str()); + let own = OWN_APPS.contains(&app.as_str()) || (app == "ACAD" && matches!(kind, "DIMENSION" | "DIMSTYLE")); + if app.is_empty() || own || out.len() + chunk.len() > MAX_XDATA_GROUPS { + continue; + } + let mut depth = 0i32; + let mut ok = true; + for t in chunk.iter().skip(1) { + match (t.code, t.str().trim()) { + (c, _) if c < 1000 => ok = false, + (1002, "{") => depth += 1, + (1002, "}") => { + depth -= 1; + ok &= depth >= 0; + } + _ => {} + } + } + if ok && depth == 0 { + out.extend(chunk.iter().map(raw)); + } + } + out +} + +/// The groups of an entity's AcDbEntity subclass (after `100 AcDbEntity`, up to the next `100`). +fn entity_groups(tags: &[Tag]) -> &[Tag] { + let Some(start) = tags.iter().position(|t| t.code == 100 && t.str().trim() == "AcDbEntity") else { return &[] }; + let rest = tags.get(start + 1..).unwrap_or(&[]); + let end = rest.iter().position(|t| t.code == 100 || t.code >= 1000).unwrap_or(rest.len()); + rest.get(..end).unwrap_or(&[]) +} + +/// What an entity record (`entity`) or a table record carries beyond the model; reactors and +/// the extension dictionary only when kept (`kept`). +fn extra(kind: &str, tags: &[Tag], entity: bool, unknown: bool, kept: &HashSet) -> ForeignExtra { + let h = head(tags); + let mut x = ForeignExtra { + xdict: h.xdict.filter(|x| kept.contains(x)).and_then(|x| handle(&x)), + reactors: h.reactors.iter().filter(|r| kept.contains(*r)).filter_map(|r| handle(r)).collect(), + xdata: foreign_xdata(kind, tags), + ..ForeignExtra::default() + }; + // An unknown entity's own groups are written as they are; of table records, layers have a + // colour book colour and a material. + if !unknown && (entity || kind == "LAYER") { + let props = + if entity { entity_groups(tags) } else { tags.get(..tags.iter().position(|t| t.code >= 1000).unwrap_or(tags.len())).unwrap_or(&[]) }; + let book = props.iter().find(|t| t.code == 430).map(Tag::str); + let rgb = props.iter().find(|t| t.code == 420).map(Tag::str); + if let (Some(book), Some(rgb)) = (book, rgb) + && !book.trim().is_empty() + { + x.color_book = Some((rgb, book)); + } + x.props = props.iter().filter(|t| matches!(t.code, 347 | 284)).map(raw).collect(); + } + x +} + +/// Every entity of the drawing (model space, layouts, blocks). +fn all_entities(d: &Drawing) -> impl Iterator { + std::iter::once(&d.model) + .chain(d.layouts.iter().map(|l| &l.entities)) + .chain(d.blocks.values().map(|b| &b.entities)) + .flat_map(|s| s.iter().map(|e| e.as_ref())) +} + +/// Keep what `secs` holds that the reader did not model in `d.foreign`. +pub(crate) fn read(secs: &[Section], d: &mut Drawing) { + let get = |name: &str| secs.iter().find(|s| s.name == name).map(|s| split(&s.tags)).unwrap_or_default(); + let (classes, tables, blocks, entities, objects) = (get("CLASSES"), get("TABLES"), get("BLOCKS"), get("ENTITIES"), get("OBJECTS")); + // The drawing's entities by handle: whether each is of a type CADCraft doesn't model. + let mut ents: HashMap = HashMap::new(); + for e in all_entities(d) { + ents.entry(e.handle.hex()).or_insert(matches!(e.kind, EntityKind::Unknown(_))); + } + let ids = identities(&tables, &blocks, &objects); + // Extension dictionaries of entities and records: owner → dictionary. + let mut xdict_of: HashMap = HashMap::new(); + for (kind, _, t) in tables.iter().chain(&blocks).chain(&entities) { + if kind == "ENDBLK" { + continue; + } + let h = head(t); + if let (Some(o), Some(x)) = (h.handle, h.xdict) { + xdict_of.entry(o).or_insert(x); + } + } + // Objects by handle: the first of each, and none that has an entity's handle. + let mut by_handle: HashMap = HashMap::new(); + for (i, (_, _, t)) in objects.iter().enumerate() { + if let Some(h) = head(t).handle.filter(|h| handle(h).is_some() && !ents.contains_key(h)) { + by_handle.entry(h).or_insert(i); + } + } + let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle); + let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default(); + let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone()); + // An extension dictionary of an entity or record (not of another object). + let is_xdict = |k: usize| { + let Some((_, _, t)) = objects.get(k) else { return false }; + let hd = head(t); + let (Some(h), Some(o)) = (hd.handle, hd.owner) else { return false }; + !by_handle.contains_key(&o) && xdict_of.get(&o) == Some(&h) && (ents.contains_key(&o) || ids.contains_key(&o)) + }; + let modelled_entry = |t: &[Tag], h: &str| entries(t).iter().any(|(k, eh)| eh == h && MODELLED_XDICT_KEYS.contains(&up(k).as_str())); + // Which objects to keep: those under a named object dictionary entry CADCraft doesn't model, + // under the extension dictionary of an entity or record (minus modelled entries; one left + // empty goes), or owned by an entity of a type CADCraft doesn't model. Memoized along each + // ownership chain; cycles and over-long chains are not kept. + let mut memo: HashMap = HashMap::new(); + for i in 0..objects.len() { + let mut path: Vec = Vec::new(); + let mut cur = i; + let verdict = loop { + if let Some(&v) = memo.get(&cur) { + break v; + } + if path.contains(&cur) || path.len() >= MAX_DEPTH { + break false; + } + path.push(cur); + let Some((_, _, t)) = objects.get(cur) else { break false }; + let hd = head(t); + let (Some(h), Some(o)) = (hd.handle, hd.owner) else { break false }; + if by_handle.get(&h) != Some(&cur) { + break false; + } + if Some(&o) == root.as_ref() { + break root_key(&h).is_some_and(|k| !modelled_key(&k)); + } + if let Some(&j) = by_handle.get(&o) { + if is_xdict(j) && objects.get(j).is_some_and(|p| modelled_entry(p.2, &h)) { + break false; + } + cur = j; + continue; + } + if is_xdict(cur) { + break entries(t).iter().any(|(k, _)| !MODELLED_XDICT_KEYS.contains(&up(k).as_str())); + } + break ents.get(&o) == Some(&true); + }; + for p in path { + memo.insert(p, verdict); + } + } + let mut f = Foreign::default(); + let mut kept: HashSet = HashSet::new(); + for (i, (kind, _, t)) in objects.iter().enumerate() { + if memo.get(&i) != Some(&true) || f.objects.len() >= MAX_OBJECTS { + continue; + } + let Some(h) = head(t).handle.and_then(|h| handle(&h)) else { continue }; + let mut tags: Vec = t.iter().map(raw).collect(); + if is_xdict(i) { + strip_entries(&mut tags); + } + if head(t).owner == root + && let Some(key) = root_key(&h.hex()) + { + f.dictionary.push((key, h)); + } + kept.insert(h.hex()); + f.objects.push(ForeignObject { handle: h, kind: kind.clone(), tags }); + } + // Extras of entities, tables, table records and blocks. + for (kind, _, t) in blocks.iter().chain(&entities) { + if matches!(kind.as_str(), "BLOCK" | "ENDBLK") { + continue; + } + let Some(h) = head(t).handle else { continue }; + let Some(&unknown) = ents.get(&h) else { continue }; + let x = extra(kind, t, true, unknown, &kept); + if let (Some(hh), false) = (handle(&h), x.is_empty()) { + f.entities.entry(hh).or_insert(x); + } + } + for (kind, _, t) in tables.iter().chain(blocks.iter().filter(|(k, _, _)| k == "BLOCK")) { + let Some(key) = head(t).handle.and_then(|h| ids.get(&h).cloned()) else { continue }; + let x = extra(kind, t, kind == "BLOCK", false, &kept); + if !x.is_empty() { + f.records.entry(key).or_insert(x); + } + } + // The classes of kept objects and of entities of types CADCraft doesn't model. + let mut types: HashSet = f.objects.iter().map(|o| up(&o.kind)).collect(); + types.extend(all_entities(d).filter_map(|e| match &e.kind { + EntityKind::Unknown(u) => Some(up(&u.dxf_type)), + _ => None, + })); + for (kind, _, t) in &classes { + if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS { + f.classes.push(t.iter().map(raw).collect()); + } + } + // The roles of the handles kept data refers to. + let mut refs: Vec = Vec::new(); + let mut note = |tags: &[RawTag]| refs.extend(tags.iter().filter(|r| is_pointer(r.code)).map(|r| norm(&r.value))); + for o in &f.objects { + note(&o.tags); + } + for x in f.entities.values().chain(f.records.values()) { + note(&x.xdata); + note(&x.props); + } + for e in all_entities(d) { + if let EntityKind::Unknown(u) = &e.kind { + note(&u.tags); + } + } + for r in &refs { + if let (Some(h), Some(k), false) = (handle(r), ids.get(r), kept.contains(r)) { + f.targets.insert(h, k.clone()); + } + } + if !f.is_empty() { + d.foreign = Arc::new(f); + } +} + +/// Drop a dictionary's modelled entries (key and handle) from its groups. +fn strip_entries(tags: &mut Vec) { + let mut out = Vec::with_capacity(tags.len()); + let mut i = 0; + while let Some(t) = tags.get(i) { + if t.code == 3 && MODELLED_XDICT_KEYS.contains(&up(&t.value).as_str()) && tags.get(i + 1).is_some_and(|n| matches!(n.code, 350 | 360)) { + i += 2; + continue; + } + out.push(t.clone()); + i += 1; + } + *tags = out; +} + +/// An entity of a type CADCraft doesn't model, as read, with its handle, owner, space and layer +/// from the drawing. Its reactors, extension dictionary and xdata are added by [`finish`] (they +/// are kept with the drawing's other foreign data); its references are remapped there too. +pub(crate) fn unknown_entity(u: &Unknown, e: &Entity, owner: &str, paper: bool) -> Vec { + let kind = up(&u.dxf_type); + if STRUCTURAL.contains(&kind.as_str()) || u.dxf_type.trim() != u.dxf_type { + return Vec::new(); + } + let mut out = vec![Tag::s(0, u.dxf_type.clone()), Tag::s(5, e.handle.hex()), Tag::s(330, owner)]; + let fixed = out.len(); + let mut in_group = false; + let mut owner_seen = false; + let mut layer = false; + let mut paper_done = !paper; + for r in u.tags.iter().take_while(|r| r.code < 1000) { + match r.code { + 102 => { + in_group = r.value.trim().starts_with('{'); + continue; + } + _ if in_group => continue, + 5 | 67 => continue, + 330 if !owner_seen => { + owner_seen = true; + continue; + } + 8 => { + layer = true; + out.push(Tag::s(8, e.common.layer.clone())); + continue; + } + _ => {} + } + out.push(tag(r)); + if r.code == 100 && r.value.trim() == "AcDbEntity" && !paper_done { + out.push(Tag::i(67, 1)); + paper_done = true; + } + } + let mut lead = Vec::new(); + if !paper_done { + lead.push(Tag::i(67, 1)); + } + if !layer { + lead.push(Tag::s(8, e.common.layer.clone())); + } + out.splice(fixed..fixed, lead); + out +} + +/// A written record: section, type, the index of its `0` group and the end of its groups. +struct Rec { + sec: String, + kind: String, + start: usize, + end: usize, +} + +fn written_records(tags: &[Tag]) -> Vec { + let mut out: Vec = Vec::new(); + let mut sec = String::new(); + for (kind, start, body) in split(tags) { + if kind == "SECTION" { + sec = body.iter().find(|t| t.code == 2).map(Tag::str).unwrap_or_default(); + } + out.push(Rec { sec: sec.clone(), kind, start, end: start + 1 + body.len() }); + if out.last().is_some_and(|r| r.kind == "ENDSEC") { + sec.clear(); + } + } + out +} + +/// Remaps the kept data's handles onto a written file. +struct Map<'a> { + f: &'a Foreign, + /// Kept objects that are written: file handle → new handle. + alive: HashMap, + /// Kept objects, written or not (file handles). + raw: HashSet, + /// Role → handle in the written file. + roles: HashMap, + /// Entities in the written file. + entities: HashSet, +} + +impl Map<'_> { + /// The written handle for a kept reference, `Some("0")` for the null handle, `None` when what + /// it named is gone. + fn get(&self, old: &str) -> Option { + let v = norm(old); + if v.is_empty() || v == "0" { + return Some("0".into()); + } + if self.raw.contains(&v) { + return self.alive.get(&v).cloned(); + } + let h = handle(&v)?; + if let Some(role) = self.f.targets.get(&h) { + return self.roles.get(role).cloned(); + } + self.entities.contains(&v).then_some(v) + } + + /// A kept group with its handle remapped (a dangling one becomes null). + fn group(&self, r: &RawTag) -> Tag { + if is_pointer(r.code) { Tag::s(r.code, self.get(&r.value).unwrap_or_else(|| "0".into())) } else { tag(r) } + } + + /// A `102 {NAME` group of the handles that still exist (nothing if none does). + fn handle_group(&self, out: &mut Vec, name: &str, code: i32, handles: &[String]) { + let live: Vec = handles.iter().filter_map(|h| self.get(h)).filter(|h| h != "0").collect(); + if live.is_empty() { + return; + } + out.push(Tag::s(102, format!("{{{name}"))); + out.extend(live.into_iter().map(|h| Tag::s(code, h))); + out.push(Tag::s(102, "}")); + } + + /// A kept object's groups, remapped. + fn object(&self, o: &ForeignObject, out: &mut Vec) { + out.push(Tag::s(0, o.kind.clone())); + let dict = is_dictionary(&o.kind); + let mut i = 0; + while let Some(r) = o.tags.get(i) { + i += 1; + if r.code == 102 && r.value.trim().starts_with('{') { + let name = r.value.trim().trim_start_matches('{').to_string(); + let mut hs = Vec::new(); + let mut code = 330; + while let Some(n) = o.tags.get(i) { + if n.code == 102 { + if n.value.trim() == "}" { + i += 1; + } + break; + } + code = n.code; + hs.push(n.value.clone()); + i += 1; + } + self.handle_group(out, &name, code, &hs); + continue; + } + if dict && r.code == 3 && o.tags.get(i).is_some_and(|n| matches!(n.code, 350 | 360)) { + let target = o.tags.get(i).map(|n| (n.code, self.get(&n.value))); + i += 1; + if let Some((code, Some(h))) = target.filter(|(_, h)| h.as_deref().is_some_and(|h| h != "0")) { + out.push(tag(r)); + out.push(Tag::s(code, h)); + } + continue; + } + if r.code == 5 { + out.push(Tag::s(5, self.get(&r.value).unwrap_or_else(|| "0".into()))); + continue; + } + out.push(self.group(r)); + } + } +} + +/// The extras of a written record, if the drawing keeps any for it. +fn extra_for<'a>(f: &'a Foreign, rec: &Rec, body: &[Tag], block: &str) -> Option<&'a ForeignExtra> { + match (rec.sec.as_str(), rec.kind.as_str()) { + ("BLOCKS", "BLOCK") => f.records.get(&("BLOCK".to_string(), block.to_string())), + ("BLOCKS", "ENDBLK") => None, + ("ENTITIES" | "BLOCKS", _) => f.entities.get(&handle(&head(body).handle?)?), + ("TABLES", kind) => { + let name = up(&val(body, 2).unwrap_or_default()); + f.records.get(&(kind.to_string(), name)) + } + _ => None, + } +} + +/// Every Unknown entity's handle (upper case). +fn unknown_handles(d: &Drawing) -> HashSet { + all_entities(d).filter(|e| matches!(e.kind, EntityKind::Unknown(_))).map(|e| e.handle.hex()).collect() +} + +/// Put the drawing's foreign data back into a written file's tags (see the module comment). +pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { + let f = d.foreign.as_ref(); + let unknown = unknown_handles(d); + if f.is_empty() && unknown.is_empty() { + return; + } + let recs = written_records(tags); + let body = |r: &Rec| tags.get(r.start + 1..r.end).unwrap_or(&[]); + let by_sec = |name: &str| -> Recs { + recs.iter().filter(|r| r.sec == name && r.kind != "SECTION" && r.kind != "ENDSEC").map(|r| (r.kind.clone(), r.start, body(r))).collect() + }; + let (tables, blocks, objects) = (by_sec("TABLES"), by_sec("BLOCKS"), by_sec("OBJECTS")); + let mut roles: HashMap = HashMap::new(); + for (h, k) in identities(&tables, &blocks, &objects) { + roles.entry(k).or_insert(h); + } + let mut written: HashSet = recs.iter().filter_map(|r| head(body(r)).handle).collect(); + // Entities of types CADCraft doesn't model, placed among the written ones. + let planned = plan_unknown(d, &recs, tags, &roles); + for list in planned.values() { + for (_, t) in list { + written.extend(head(t.get(1..).unwrap_or(&[])).handle); + } + } + let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, s, _)| *s); + let root_keys: HashSet = + objects.iter().find(|(_, s, _)| Some(*s) == root).map(|(_, _, t)| entries(t).into_iter().map(|(k, _)| up(&k)).collect()).unwrap_or_default(); + // Extension dictionaries that go on a written record (one without its own). + let mut block = String::new(); + let mut attached: HashSet = HashSet::new(); + for r in &recs { + if r.kind == "BLOCK" { + block = up(&val(body(r), 2).unwrap_or_default()); + } + if let Some(x) = extra_for(f, r, body(r), &block) + && let Some(xd) = x.xdict + && head(body(r)).xdict.is_none() + { + attached.insert(xd.hex()); + } + } + for (_, t) in planned.values().flatten() { + let x = head(t.get(1..).unwrap_or(&[])).handle.and_then(|h| handle(&h)).and_then(|h| f.entities.get(&h)); + attached.extend(x.and_then(|x| x.xdict).map(Handle::hex)); + } + // Which kept objects are written: those whose owner chain ends at a written owner. + let objs: HashMap = f.objects.iter().map(|o| (o.handle.hex(), o)).collect(); + let dict_entries: HashMap = f.dictionary.iter().map(|(k, h)| (h.hex(), k.as_str())).collect(); + let nod = f.targets.iter().filter(|(_, k)| k.0 == "NOD" && k.1.is_empty()).map(|(h, _)| h.hex()).collect::>(); + let owners: HashMap = f + .objects + .iter() + .filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::>()).owner?))) + .collect(); + let mut alive_memo: HashMap = HashMap::new(); + for o in &f.objects { + let mut path: Vec = Vec::new(); + let mut cur = o.handle.hex(); + let verdict = loop { + if let Some(&v) = alive_memo.get(&cur) { + break v; + } + if path.contains(&cur) || path.len() >= MAX_DEPTH { + break false; + } + path.push(cur.clone()); + let Some(owner) = owners.get(&cur).cloned() else { break false }; + if objs.contains_key(&owner) { + cur = owner; + continue; + } + if nod.contains(&owner) { + break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some(); + } + if attached.contains(&cur) { + break true; + } + break unknown.contains(&owner) && written.contains(&owner); + }; + for p in path { + alive_memo.insert(p, verdict); + } + } + // New handles for the written kept objects, above every written handle and the seed. + let mut next = seed(tags).max(max_handle(tags).saturating_add(1)); + let mut alive: HashMap = HashMap::new(); + for o in f.objects.iter().filter(|o| alive_memo.get(&o.handle.hex()) == Some(&true)) { + alive.insert(o.handle.hex(), format!("{next:X}")); + next = next.saturating_add(1); + } + let entities: HashSet = all_entities(d).map(|e| e.handle.hex()).filter(|h| written.contains(h)).collect(); + let map = Map { f, alive, raw: objs.keys().cloned().collect(), roles, entities }; + let class_names: HashSet = recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).collect(); + let mut out: Vec = Vec::with_capacity(tags.len() + f.objects.iter().map(|o| o.tags.len() + 1).sum::()); + let mut block = String::new(); + for r in &recs { + let b = body(r); + if r.kind == "BLOCK" { + block = up(&val(b, 2).unwrap_or_default()); + } + if r.kind == "ENDSEC" && r.sec == "CLASSES" { + for c in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) { + out.push(Tag::s(0, "CLASS")); + out.extend(c.iter().map(tag)); + } + } + if r.kind == "ENDSEC" && r.sec == "OBJECTS" { + for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) { + map.object(o, &mut out); + } + } + for (sec, t) in planned.get(&r.start).into_iter().flatten() { + let (Some(zero), Some(ub)) = (t.first(), t.get(1..)) else { continue }; + let ur = Rec { sec: sec.clone(), kind: zero.str(), start: 0, end: 0 }; + let x = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| f.entities.get(&h)); + out.push(zero.clone()); + record(&map, &ur, ub, x, true, false, &mut out); + } + out.extend(tags.get(r.start).cloned()); + let x = extra_for(f, r, b, &block); + if x.is_none() && Some(r.start) != root { + out.extend(b.iter().cloned()); + continue; + } + record(&map, r, b, x, false, Some(r.start) == root, &mut out); + } + add_appids(&mut out); + let top = max_handle(&out).saturating_add(1); + if let Some(t) = seed_index(&out).and_then(|i| out.get_mut(i)) + && Handle::parse_hex(&t.str()).is_none_or(|h| h.0 < top) + { + *t = Tag::s(5, format!("{top:X}")); + } + *tags = out; +} + +/// Where the entities of types CADCraft doesn't model go: each before the next written entity of +/// its space or block (else at the end of it), as `(section, record)` lists by the index of the +/// record they precede. Owners come from the written block records. +fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap) -> HashMap)>> { + let mut out: HashMap)>> = HashMap::new(); + let body = |r: &Rec| tags.get(r.start + 1..r.end).unwrap_or(&[]); + let mut at: HashMap = HashMap::new(); + for r in recs.iter().filter(|r| matches!(r.sec.as_str(), "ENTITIES" | "BLOCKS") && !matches!(r.kind.as_str(), "BLOCK" | "ENDBLK")) { + if let Some(h) = head(body(r)).handle { + at.entry(h).or_insert(r.start); + } + } + let ents_end = recs.iter().find(|r| r.sec == "ENTITIES" && r.kind == "ENDSEC").map(|r| r.start); + let endblk = |name: &str| { + let i = recs.iter().position(|r| r.sec == "BLOCKS" && r.kind == "BLOCK" && val(body(r), 2).is_some_and(|n| up(&n) == up(name)))?; + recs.iter().skip(i).find(|r| r.kind == "ENDBLK").map(|r| r.start) + }; + let br = |name: &str| roles.get(&("BLOCK_RECORD".to_string(), up(name))).cloned(); + let first_at = |s: &cadcraft_doc::EntityStore| s.iter().find_map(|e| at.get(&e.handle.hex()).copied()); + let mut layouts: Vec<&cadcraft_doc::Layout> = d.layouts.iter().collect(); + layouts.sort_by_key(|l| l.tab_order); + // (entities, owner, paper space, end of the space, section) + let mut stores: Vec<(&cadcraft_doc::EntityStore, Option, bool, Option, &str)> = Vec::new(); + let model_end = layouts.first().and_then(|l| first_at(&l.entities)).or(ents_end); + stores.push((&d.model, br("*Model_Space"), false, model_end, "ENTITIES")); + for (i, l) in layouts.iter().enumerate() { + if i == 0 { + stores.push((&l.entities, br("*Paper_Space"), true, ents_end, "ENTITIES")); + } else { + let name = format!("*Paper_Space{}", i - 1); + stores.push((&l.entities, br(&name), true, endblk(&name), "BLOCKS")); + } + } + for b in d.blocks.values() { + stores.push((&b.entities, br(&b.name), false, endblk(&b.name), "BLOCKS")); + } + for (store, owner, paper, end, sec) in stores { + let (Some(owner), Some(end)) = (owner, end) else { continue }; + let mut pending: Vec<(String, Vec)> = Vec::new(); + for e in store.iter() { + match &e.kind { + EntityKind::Unknown(u) => { + let t = unknown_entity(u, e, &owner, paper); + if !t.is_empty() { + pending.push((sec.to_string(), t)); + } + } + _ => { + if let Some(&p) = at.get(&e.handle.hex()) + && !pending.is_empty() + { + out.entry(p).or_default().append(&mut pending); + } + } + } + } + if !pending.is_empty() { + out.entry(end).or_default().append(&mut pending); + } + } + out +} + +/// A written record with its kept extras, remapped references (unknown entities) and kept +/// dictionary entries (the named object dictionary). +fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool, root: bool, out: &mut Vec) { + let h = head(b); + let entity = matches!(r.sec.as_str(), "ENTITIES" | "BLOCKS") && r.kind != "BLOCK"; + let xdata_at = b.iter().position(|t| t.code == 1001).unwrap_or(b.len()); + let own_apps: HashSet = b.iter().filter(|t| t.code == 1001).map(|t| up(&t.str())).collect(); + // Where unmodelled properties go: the end of AcDbEntity, else before the xdata. + let props_at = if entity { + b.iter().position(|t| t.code == 100 && t.str().trim() == "AcDbEntity").and_then(|s| { + let rest = b.get(s + 1..xdata_at)?; + Some(s + 1 + rest.iter().position(|t| t.code == 100).unwrap_or(rest.len())) + }) + } else { + None + } + .unwrap_or(xdata_at); + let rgb = b.iter().take(xdata_at).find(|t| t.code == 420).map(Tag::str); + let has_book = b.iter().take(xdata_at).any(|t| t.code == 430); + let handle_at = b.iter().position(|t| matches!(t.code, 5 | 105)); + let mut seen_owner = false; + for (i, t) in b.iter().enumerate() { + if i == props_at + && let Some(x) = x + { + if let Some((want, book)) = &x.color_book + && !has_book + && rgb.as_deref() == Some(want.as_str()) + { + out.push(Tag::s(430, book.clone())); + } + out.extend(x.props.iter().map(|p| map.group(p))); + } + if i == xdata_at + && let Some(x) = x + { + xdata(map, x, &own_apps, out); + } + let mut t = t.clone(); + if unknown && i < xdata_at && is_pointer(t.code) && t.code != 102 { + if t.code == 330 && !seen_owner { + seen_owner = true; + } else { + t = Tag::s(t.code, map.get(&t.str()).unwrap_or_else(|| "0".into())); + } + } + out.push(t); + if Some(i) == handle_at + && let Some(x) = x + { + if !h.has_reactors { + let rs: Vec = x.reactors.iter().map(|r| r.hex()).collect(); + map.handle_group(out, "ACAD_REACTORS", 330, &rs); + } + if h.xdict.is_none() + && let Some(xd) = x.xdict + { + map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); + } + } + } + if let Some(x) = x { + if props_at == b.len() { + if let Some((want, book)) = &x.color_book + && !has_book + && rgb.as_deref() == Some(want.as_str()) + { + out.push(Tag::s(430, book.clone())); + } + out.extend(x.props.iter().map(|p| map.group(p))); + } + if xdata_at == b.len() { + xdata(map, x, &own_apps, out); + } + } + if root { + for (key, h) in &map.f.dictionary { + if let Some(nh) = map.alive.get(&h.hex()) + && !b.iter().any(|t| t.code == 3 && up(&t.str()) == up(key)) + { + out.push(Tag::s(3, key.clone())); + out.push(Tag::s(350, nh.clone())); + } + } + } +} + +/// Kept xdata of applications the record doesn't already carry, handles remapped. +fn xdata(map: &Map, x: &ForeignExtra, own: &HashSet, out: &mut Vec) { + let mut skip = false; + for r in &x.xdata { + if r.code == 1001 { + skip = own.contains(&up(&r.value)); + } + if !skip { + out.push(map.group(r)); + } + } +} + +/// APPID records for every application with xdata in the file, added to the APPID table. +fn add_appids(out: &mut Vec) { + let used: BTreeMap = out.iter().filter(|t| t.code == 1001).map(|t| (up(&t.str()), t.str().trim().to_string())).collect(); + let recs = written_records(out); + let Some(table) = recs.iter().position(|r| { + r.sec == "TABLES" && r.kind == "TABLE" && val(out.get(r.start + 1..r.end).unwrap_or(&[]), 2).is_some_and(|n| up(&n) == "APPID") + }) else { + return; + }; + let Some(t) = recs.get(table) else { return }; + let table_h = head(out.get(t.start + 1..t.end).unwrap_or(&[])).handle.unwrap_or_else(|| "0".into()); + let Some(end) = recs.iter().skip(table).find(|r| r.kind == "ENDTAB") else { return }; + let have: HashSet = recs + .iter() + .skip(table + 1) + .take_while(|r| r.kind == "APPID") + .filter_map(|r| val(out.get(r.start + 1..r.end).unwrap_or(&[]), 2)) + .map(|n| up(&n)) + .collect(); + let missing: Vec<&String> = used.iter().filter(|(k, _)| !have.contains(*k)).map(|(_, v)| v).collect(); + if missing.is_empty() { + return; + } + // New handles above the seed, which is then moved past them. + let mut next = seed(out).max(max_handle(out).saturating_add(1)); + let mut add = Vec::new(); + for app in &missing { + add.extend([ + Tag::s(0, "APPID"), + Tag::s(5, format!("{next:X}")), + Tag::s(330, table_h.clone()), + Tag::s(100, "AcDbSymbolTableRecord"), + Tag::s(100, "AcDbRegAppTableRecord"), + Tag::s(2, (*app).clone()), + Tag::i(70, 0), + ]); + next = next.saturating_add(1); + } + let end_at = end.start; + if let Some(count) = out.get_mut(t.start + 1..t.end).and_then(|b| b.iter_mut().find(|x| x.code == 70)) { + *count = Tag::i(70, count.i64().saturating_add(i64::try_from(missing.len()).unwrap_or(0))); + } + out.splice(end_at..end_at, add); +} + +/// The index of the `$HANDSEED` value in the header. +fn seed_index(tags: &[Tag]) -> Option { + tags.windows(2).position(|w| matches!(w, [a, b] if a.code == 9 && a.str() == "$HANDSEED" && b.code == 5)).map(|i| i + 1) +} + +/// The header's next free handle. +fn seed(tags: &[Tag]) -> u64 { + seed_index(tags).and_then(|i| tags.get(i)).and_then(|t| Handle::parse_hex(&t.str())).map_or(0, |h| h.0) +} + +/// The largest handle of a record (groups 5 and 105). +fn max_handle(tags: &[Tag]) -> u64 { + tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0) +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1ea09c18..4e8248f8 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1624,6 +1624,7 @@ pub fn read(bytes: &[u8]) -> Result { if d.layer("0").is_none() { d.layers.insert(0, Layer::default()); } + crate::dxf_raw::read(&secs, &mut d); Ok(d) } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 99723fa0..34158e5e 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2257,6 +2257,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { if let Some(t) = w.t.get_mut(seed_pos + 1) { *t = Tag::s(5, seed); } + crate::dxf_raw::finish(&mut w.t, d); mark_xrefs(&mut w.t, d); w.t } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 96dd7125..c681a751 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -2,6 +2,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// What a DXF file holds that CADCraft doesn't model, kept and written back (#528). +mod dxf_raw; + mod dxf_ext; mod dxf_image; mod dxf_mleader; diff --git a/crates/io/tests/roundtrip_audit.rs b/crates/io/tests/roundtrip_audit.rs new file mode 100644 index 00000000..6a9fa07f --- /dev/null +++ b/crates/io/tests/roundtrip_audit.rs @@ -0,0 +1,966 @@ +//! What survives DXF open → save → reopen of data CADCraft does not model (#528): unknown +//! entities and objects, other applications' xdata, extension dictionaries, layer states, colour +//! books, hyperlinks, DICTIONARYVAR objects, XRECORDs and persistent reactors. One test per item. +//! +//! The fixture is synthetic, written from the public DXF Reference: a drawing as another program +//! might save it, with an application `ACME` that defines its own entity and object types. + +use std::collections::HashMap; + +use cadcraft_doc::{Drawing, EntityKind}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_io::{read_dxf, write_dxf}; + +/// DXF text from lines of `code value` (the value may be empty). +fn dxf(src: &str) -> String { + let mut out = String::new(); + for line in src.lines().map(str::trim).filter(|l| !l.is_empty()) { + let (code, value) = line.split_once(' ').unwrap_or((line, "")); + out.push_str(&format!("{code}\n{value}\n")); + } + out +} + +const HEADER: &str = " +0 SECTION +2 HEADER +9 $ACADVER +1 AC1018 +9 $HANDSEED +5 100 +0 ENDSEC +0 SECTION +2 CLASSES +0 CLASS +1 ACME_WIDGET +2 AcmeWidget +3 AcmeApp +90 1025 +280 0 +281 1 +0 CLASS +1 ACME_OBJECT +2 AcmeObject +3 AcmeApp +90 0 +280 0 +281 0 +0 CLASS +1 DICTIONARYVAR +2 AcDbDictionaryVar +3 ObjectDBX Classes +90 0 +280 0 +281 0 +0 ENDSEC +"; + +const TABLES: &str = " +0 SECTION +2 TABLES +0 TABLE +2 LAYER +5 2 +102 {ACAD_XDICTIONARY +360 60 +102 } +330 0 +100 AcDbSymbolTable +70 2 +0 LAYER +5 10 +102 {ACAD_XDICTIONARY +360 63 +102 } +330 2 +100 AcDbSymbolTableRecord +100 AcDbLayerTableRecord +2 0 +70 0 +62 7 +6 Continuous +0 LAYER +5 11 +330 2 +100 AcDbSymbolTableRecord +100 AcDbLayerTableRecord +2 WALLS +70 0 +62 1 +420 13382451 +430 RAL$RAL 3020 +6 Continuous +370 -3 +1001 ACME +1000 layer-data +1070 7 +0 ENDTAB +0 TABLE +2 UCS +5 3 +330 0 +100 AcDbSymbolTable +70 1 +0 UCS +5 12 +330 3 +100 AcDbSymbolTableRecord +100 AcDbUCSTableRecord +2 TILTED +70 0 +10 1 +20 2 +30 3 +11 0 +21 1 +31 0 +12 -1 +22 0 +32 0 +79 0 +146 5.5 +0 ENDTAB +0 TABLE +2 APPID +5 4 +330 0 +100 AcDbSymbolTable +70 3 +0 APPID +5 13 +330 4 +100 AcDbSymbolTableRecord +100 AcDbRegAppTableRecord +2 ACAD +70 0 +0 APPID +5 14 +330 4 +100 AcDbSymbolTableRecord +100 AcDbRegAppTableRecord +2 ACME +70 0 +0 APPID +5 15 +330 4 +100 AcDbSymbolTableRecord +100 AcDbRegAppTableRecord +2 PE_URL +70 0 +0 ENDTAB +0 TABLE +2 BLOCK_RECORD +5 1 +330 0 +100 AcDbSymbolTable +70 3 +0 BLOCK_RECORD +5 1F +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 *Model_Space +340 0 +0 BLOCK_RECORD +5 1E +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 *Paper_Space +340 0 +0 BLOCK_RECORD +5 1D +102 {ACAD_XDICTIONARY +360 65 +102 } +330 1 +100 AcDbSymbolTableRecord +100 AcDbBlockTableRecord +2 DOOR +340 0 +70 0 +280 1 +1001 ACME +1000 block-record-data +0 ENDTAB +0 ENDSEC +"; + +const BLOCKS: &str = " +0 SECTION +2 BLOCKS +0 BLOCK +5 20 +330 1F +100 AcDbEntity +8 0 +100 AcDbBlockBegin +2 *Model_Space +70 0 +10 0 +20 0 +30 0 +3 *Model_Space +1 +0 ENDBLK +5 21 +330 1F +100 AcDbEntity +8 0 +100 AcDbBlockEnd +0 BLOCK +5 22 +330 1E +100 AcDbEntity +67 1 +8 0 +100 AcDbBlockBegin +2 *Paper_Space +70 0 +10 0 +20 0 +30 0 +3 *Paper_Space +1 +0 ENDBLK +5 23 +330 1E +100 AcDbEntity +67 1 +8 0 +100 AcDbBlockEnd +0 BLOCK +5 24 +330 1D +100 AcDbEntity +8 0 +100 AcDbBlockBegin +2 DOOR +70 0 +10 0 +20 0 +30 0 +3 DOOR +1 +4 A door leaf +1001 ACME +1000 block-entity-data +0 LINE +5 26 +330 1D +100 AcDbEntity +8 0 +100 AcDbLine +10 0 +20 0 +30 0 +11 1 +21 0 +31 0 +1001 ACME +1000 in-block +0 ACME_WIDGET +5 32 +330 1D +100 AcDbEntity +8 0 +100 AcDbAcmeWidget +10 0.5 +20 0.5 +30 0 +1 widget-in-block +0 ENDBLK +5 25 +330 1D +100 AcDbEntity +8 0 +100 AcDbBlockEnd +0 ENDSEC +"; + +const ENTITIES: &str = " +0 SECTION +2 ENTITIES +0 LINE +5 2A +102 {ACAD_REACTORS +330 42 +102 } +102 {ACAD_XDICTIONARY +360 2B +102 } +330 1F +100 AcDbEntity +8 WALLS +62 1 +420 16711680 +430 RAL$RAL 3000 +440 33554559 +100 AcDbLine +10 0 +20 0 +30 0 +11 10 +21 0 +31 0 +1001 ACME +1000 hello +1070 42 +1005 42 +1001 PE_URL +1000 https://example.com/spec +1002 { +1000 Specification +1002 { +1071 0 +1002 } +1002 } +0 ACME_WIDGET +5 30 +330 1F +100 AcDbEntity +8 WALLS +100 AcDbAcmeWidget +10 1 +20 2 +30 0 +1 widget-in-model +340 42 +341 11 +0 ACME_WIDGET +5 31 +330 1E +100 AcDbEntity +67 1 +8 0 +100 AcDbAcmeWidget +10 3 +20 4 +30 0 +1 widget-in-paper +0 INSERT +5 33 +330 1F +100 AcDbEntity +8 0 +100 AcDbBlockReference +2 DOOR +10 5 +20 5 +30 0 +0 IMAGE +5 34 +102 {ACAD_REACTORS +330 52 +102 } +330 1F +100 AcDbEntity +8 0 +100 AcDbRasterImage +90 0 +10 5 +20 5 +30 0 +11 0.01 +21 0 +31 0 +12 0 +22 0.01 +32 0 +13 640 +23 480 +340 51 +70 7 +280 0 +281 50 +282 50 +283 0 +360 52 +71 1 +91 2 +14 -0.5 +24 -0.5 +14 639.5 +24 479.5 +0 ENDSEC +"; + +const OBJECTS: &str = " +0 SECTION +2 OBJECTS +0 DICTIONARY +5 C +330 0 +100 AcDbDictionary +281 1 +3 ACAD_GROUP +350 D +3 ACAD_IMAGE_DICT +350 50 +3 ACME_DATA +350 40 +3 ACME_NESTED +350 41 +3 AcDbVariableDictionary +350 43 +0 DICTIONARY +5 D +102 {ACAD_REACTORS +330 C +102 } +330 C +100 AcDbDictionary +281 1 +0 XRECORD +5 40 +102 {ACAD_REACTORS +330 C +102 } +330 C +100 AcDbXrecord +280 1 +1 top-level-xrecord +70 7 +40 2.5 +0 DICTIONARY +5 41 +330 C +100 AcDbDictionary +281 1 +3 INNER +350 42 +0 ACME_OBJECT +5 42 +102 {ACAD_REACTORS +330 41 +330 2A +102 } +330 41 +100 AcDbAcmeObject +1 nested-object +340 2A +341 11 +0 DICTIONARY +5 43 +330 C +100 AcDbDictionary +281 1 +3 ACMEVAR +350 44 +0 DICTIONARYVAR +5 44 +102 {ACAD_REACTORS +330 43 +102 } +330 43 +100 DictionaryVariables +280 0 +1 var-value +0 DICTIONARY +5 2B +330 2A +100 AcDbDictionary +280 1 +281 1 +3 ACME_XREC +360 2D +0 XRECORD +5 2D +330 2B +100 AcDbXrecord +280 1 +1 entity-xdict-data +0 DICTIONARY +5 60 +330 2 +100 AcDbDictionary +281 1 +3 ACAD_LAYERSTATES +360 61 +0 DICTIONARY +5 61 +330 60 +100 AcDbDictionary +281 1 +3 Plan +350 62 +0 XRECORD +5 62 +102 {ACAD_REACTORS +330 61 +102 } +330 61 +100 AcDbXrecord +280 1 +91 0 +301 Plan +8 WALLS +90 0 +62 1 +0 DICTIONARY +5 63 +330 10 +100 AcDbDictionary +281 1 +3 ACME_LAYER +360 64 +0 XRECORD +5 64 +330 63 +100 AcDbXrecord +1 layer-xdict-data +0 DICTIONARY +5 65 +330 1D +100 AcDbDictionary +281 1 +3 ACME_BLOCK +360 66 +0 XRECORD +5 66 +330 65 +100 AcDbXrecord +1 block-xdict-data +0 DICTIONARY +5 50 +330 C +100 AcDbDictionary +281 1 +3 logo +350 51 +0 IMAGEDEF +5 51 +102 {ACAD_REACTORS +330 50 +330 52 +102 } +330 50 +100 AcDbRasterImageDef +90 0 +1 images/logo.png +10 640 +20 480 +11 0.25 +21 0.25 +280 1 +281 5 +0 IMAGEDEF_REACTOR +5 52 +330 34 +100 AcDbRasterImageDefReactor +90 2 +330 34 +0 ENDSEC +0 EOF +"; + +fn fixture() -> String { + dxf(&[HEADER, TABLES, BLOCKS, ENTITIES, OBJECTS].concat()) +} + +/// A written file, record by record: (section, record type, groups after the `0`). +struct Out { + recs: Vec<(String, String, Vec)>, +} + +impl Out { + fn new(text: &str) -> Out { + let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF"); + let mut recs = Vec::new(); + for s in sections(&tags) { + for (k, t) in records(&s.tags) { + recs.push((s.name.clone(), k, t)); + } + } + Out { recs } + } + fn of(&self, kind: &str) -> Vec<&[Tag]> { + self.recs.iter().filter(|(_, k, _)| k == kind).map(|(_, _, t)| t.as_slice()).collect() + } + fn in_section(&self, section: &str, kind: &str) -> Vec<&[Tag]> { + self.recs.iter().filter(|(s, k, _)| s == section && k == kind).map(|(_, _, t)| t.as_slice()).collect() + } + /// The record with handle `h` (group 5, or 105 for DIMSTYLE): (type, groups). + fn handle(&self, h: &str) -> Option<(&str, &[Tag])> { + self.recs + .iter() + .find(|(_, _, t)| t.iter().any(|x| (x.code == 5 || x.code == 105) && x.str().eq_ignore_ascii_case(h))) + .map(|(_, k, t)| (k.as_str(), t.as_slice())) + } + /// The table record or block record of type `kind` named `name`. + fn named(&self, kind: &str, name: &str) -> &[Tag] { + self.of(kind).into_iter().find(|t| s(t, 2).eq_ignore_ascii_case(name)).unwrap_or_else(|| panic!("no {kind} {name}")) + } + fn root(&self) -> &[Tag] { + self.in_section("OBJECTS", "DICTIONARY").into_iter().next().expect("named object dictionary") + } + /// The object a dictionary maps `key` to. + fn entry(&self, dict: &[Tag], key: &str) -> Option<(&str, &[Tag])> { + let h = entries(dict).into_iter().find(|(k, _)| k == key)?.1; + self.handle(&h) + } + /// Every handle reference (330/340/350/360 groups, 102 groups and 1005 xdata) that is not + /// null and names no record of the file. + fn dangling(&self) -> Vec { + let handles: std::collections::HashSet = + self.recs.iter().flat_map(|(_, _, t)| t.iter().filter(|x| x.code == 5 || x.code == 105).map(|x| x.str().to_ascii_uppercase())).collect(); + let mut out = Vec::new(); + for (_, k, t) in &self.recs { + for x in t.iter().filter(|x| matches!(x.code, 330..=369 | 1005)) { + let v = x.str().to_ascii_uppercase(); + if v != "0" && !v.is_empty() && !handles.contains(&v) { + out.push(format!("{k} {} -> {v}", x.code)); + } + } + } + out + } + fn counts(&self) -> HashMap { + let mut m = HashMap::new(); + for (_, k, _) in &self.recs { + *m.entry(k.clone()).or_insert(0) += 1; + } + m + } +} + +fn s(tags: &[Tag], code: i32) -> String { + tags.iter().find(|t| t.code == code).map(Tag::str).unwrap_or_default() +} + +fn h(tags: &[Tag]) -> String { + tags.iter().find(|t| t.code == 5 || t.code == 105).map(Tag::str).unwrap_or_default() +} + +/// The owner: the first 330 outside `102` groups. +fn owner(tags: &[Tag]) -> String { + let mut depth = 0; + for t in tags { + match (t.code, t.str().as_str()) { + (102, v) if v.starts_with('{') => depth += 1, + (102, "}") => depth -= 1, + (330, v) if depth == 0 => return v.to_string(), + _ => {} + } + } + String::new() +} + +/// The handles in a `102 {NAME` group. +fn group(tags: &[Tag], name: &str) -> Vec { + let start = tags.iter().position(|t| t.code == 102 && t.str() == format!("{{{name}")); + let Some(start) = start else { return Vec::new() }; + tags[start + 1..].iter().take_while(|t| t.code != 102).map(Tag::str).collect() +} + +/// Dictionary entries: (key, handle). +fn entries(tags: &[Tag]) -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut key = None; + for t in tags { + match t.code { + 3 => key = Some(t.str()), + 350 | 360 => { + if let Some(k) = key.take() { + out.push((k, t.str())); + } + } + _ => {} + } + } + out +} + +/// The xdata of application `app`: the groups after its `1001` up to the next application. +fn xdata(tags: &[Tag], app: &str) -> Vec<(i32, String)> { + let Some(start) = tags.iter().position(|t| t.code == 1001 && t.str() == app) else { return Vec::new() }; + tags[start + 1..].iter().take_while(|t| t.code != 1001).map(|t| (t.code, t.str())).collect() +} + +/// Read the fixture, save, reopen and save again: both saves. +fn saves(src: &str) -> (Drawing, Out, Out) { + let d = read_dxf(src.as_bytes()).expect("read fixture"); + let first = write_dxf(&d); + let d2 = read_dxf(first.as_bytes()).expect("reopen"); + let second = write_dxf(&d2); + let d3 = read_dxf(second.as_bytes()).expect("reopen again"); + (d3, Out::new(&first), Out::new(&second)) +} + +fn entity_by_text<'a>(o: &'a Out, kind: &str, text: &str) -> &'a [Tag] { + o.of(kind).into_iter().find(|t| s(t, 1) == text).unwrap_or_else(|| panic!("no {kind} `{text}`")) +} + +#[test] +fn unknown_entities_in_model_paper_and_blocks_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + assert_eq!(o.of("ACME_WIDGET").len(), 3); + let model = entity_by_text(o, "ACME_WIDGET", "widget-in-model"); + assert_eq!(owner(model), h(o.named("BLOCK_RECORD", "*Model_Space"))); + assert_eq!(s(model, 8), "WALLS"); + // Its pointers follow the objects they name. + assert_eq!(o.handle(&s(model, 340)).map(|r| r.0), Some("ACME_OBJECT")); + assert_eq!(o.handle(&s(model, 341)).map(|r| s(r.1, 2)), Some("WALLS".into())); + let paper = entity_by_text(o, "ACME_WIDGET", "widget-in-paper"); + assert_eq!(s(paper, 67), "1"); + assert_eq!(owner(paper), h(o.named("BLOCK_RECORD", "*Paper_Space"))); + let in_block = entity_by_text(o, "ACME_WIDGET", "widget-in-block"); + assert_eq!(owner(in_block), h(o.named("BLOCK_RECORD", "DOOR"))); + // It keeps its place in the drawing order: after the line, before the block reference. + let order: Vec = o.in_section("ENTITIES", "LINE").into_iter().chain(o.in_section("ENTITIES", "ACME_WIDGET")).map(h).collect(); + let pos = |x: &str| o.recs.iter().position(|(_, _, t)| h(t) == x); + assert!(order.contains(&"30".to_string()) && pos("2A") < pos("30") && pos("30") < pos("33"), "order"); + // Its class is declared. + assert!(o.of("CLASS").iter().any(|c| s(c, 1) == "ACME_WIDGET"), "ACME_WIDGET class"); + } +} + +#[test] +fn unknown_objects_in_named_and_nested_dictionaries_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let root = o.root(); + let (kind, x) = o.entry(root, "ACME_DATA").expect("ACME_DATA entry"); + assert_eq!(kind, "XRECORD"); + assert_eq!((s(x, 1), s(x, 70), s(x, 40)), ("top-level-xrecord".into(), "7".into(), "2.5".into())); + assert_eq!(owner(x), h(root)); + let (kind, nested) = o.entry(root, "ACME_NESTED").expect("ACME_NESTED entry"); + assert_eq!(kind, "DICTIONARY"); + let (kind, obj) = o.entry(nested, "INNER").expect("INNER entry"); + assert_eq!(kind, "ACME_OBJECT"); + assert_eq!(s(obj, 1), "nested-object"); + assert_eq!(owner(obj), h(nested)); + assert!(o.of("CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT"), "ACME_OBJECT class"); + // CADCraft's own dictionaries are not doubled. + let keys: Vec = entries(root).into_iter().map(|e| e.0).collect(); + for k in ["ACAD_GROUP", "ACAD_IMAGE_DICT"] { + assert_eq!(keys.iter().filter(|x| *x == k).count(), 1, "{k} in {keys:?}"); + } + } +} + +#[test] +fn foreign_xdata_on_entities_tables_and_blocks_is_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let line = o.handle("2A").expect("line").1; + let acme = xdata(line, "ACME"); + assert_eq!(acme.get(..2), Some(&[(1000, "hello".to_string()), (1070, "42".to_string())][..])); + let target = acme.get(2).map(|x| x.1.clone()).unwrap_or_default(); + assert_eq!(o.handle(&target).map(|r| r.0), Some("ACME_OBJECT"), "1005 handle follows its object"); + assert_eq!(xdata(o.named("LAYER", "WALLS"), "ACME"), vec![(1000, "layer-data".into()), (1070, "7".into())]); + assert_eq!(xdata(o.named("BLOCK_RECORD", "DOOR"), "ACME"), vec![(1000, "block-record-data".into())]); + assert_eq!(xdata(o.named("BLOCK", "DOOR"), "ACME"), vec![(1000, "block-entity-data".into())]); + let in_block = o.handle("26").expect("line in block").1; + assert_eq!(xdata(in_block, "ACME"), vec![(1000, "in-block".into())]); + // Every application used has its APPID record. + let apps: Vec = o.of("APPID").iter().map(|r| s(r, 2)).collect(); + for app in ["ACME", "PE_URL"] { + assert_eq!(apps.iter().filter(|a| *a == app).count(), 1, "{app} in {apps:?}"); + } + } +} + +#[test] +fn extension_dictionaries_on_entities_and_table_records_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let check = |rec: &[Tag], key: &str, text: &str| { + let [xd] = group(rec, "ACAD_XDICTIONARY").try_into().unwrap_or_else(|g| panic!("one extension dictionary, got {g:?}")); + let (kind, dict) = o.handle(&xd).expect("extension dictionary"); + assert_eq!(kind, "DICTIONARY"); + assert_eq!(owner(dict), h(rec), "the extension dictionary's owner"); + let (_, x) = o.entry(dict, key).unwrap_or_else(|| panic!("{key}")); + assert_eq!(s(x, 1), text); + }; + check(o.handle("2A").expect("line").1, "ACME_XREC", "entity-xdict-data"); + check(o.named("LAYER", "0"), "ACME_LAYER", "layer-xdict-data"); + check(o.named("BLOCK_RECORD", "DOOR"), "ACME_BLOCK", "block-xdict-data"); + } +} + +#[test] +fn image_imagedef_and_reactor_are_kept() { + let (d, a, b) = saves(&fixture()); + let img = d.model.iter().find_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.clone()), + _ => None, + }); + assert_eq!(img.map(|i| (i.path, i.name)), Some(("images/logo.png".into(), "logo".into()))); + for o in [&a, &b] { + assert_eq!(o.of("IMAGEDEF").len(), 1); + assert_eq!(o.of("IMAGEDEF_REACTOR").len(), 1); + let reactor = o.of("IMAGEDEF_REACTOR")[0]; + assert_eq!(o.handle(&owner(reactor)).map(|r| r.0), Some("IMAGE")); + } +} + +#[test] +fn ucs_layer_states_and_block_descriptions_are_kept() { + let (d, a, b) = saves(&fixture()); + let u = d.ucss.iter().find(|u| u.name == "TILTED").expect("UCS TILTED"); + assert_eq!((u.origin.x, u.origin.y, u.origin.z), (1.0, 2.0, 3.0)); + assert_eq!((u.x_axis.y, u.y_axis.x), (1.0, -1.0)); + assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf")); + for o in [&a, &b] { + // Layer states live in the LAYER table's extension dictionary. + let table = o.of("TABLE").into_iter().find(|t| s(t, 2) == "LAYER").expect("LAYER table"); + let [xd] = group(table, "ACAD_XDICTIONARY").try_into().expect("LAYER table extension dictionary"); + let (_, dict) = o.handle(&xd).expect("dictionary"); + let (_, states) = o.entry(dict, "ACAD_LAYERSTATES").expect("ACAD_LAYERSTATES"); + let (_, plan) = o.entry(states, "Plan").expect("layer state Plan"); + assert_eq!((s(plan, 301), s(plan, 8)), ("Plan".into(), "WALLS".into())); + } +} + +#[test] +fn true_colours_and_colour_books_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let line = o.handle("2A").expect("line").1; + assert_eq!((s(line, 420), s(line, 430)), ("16711680".into(), "RAL$RAL 3000".into())); + let walls = o.named("LAYER", "WALLS"); + assert_eq!((s(walls, 420), s(walls, 430)), ("13382451".into(), "RAL$RAL 3020".into())); + } +} + +#[test] +fn transparency_is_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + assert_eq!(s(o.handle("2A").expect("line").1, 440), "33554559"); + } +} + +#[test] +fn hyperlinks_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let url = xdata(o.handle("2A").expect("line").1, "PE_URL"); + let want: Vec<(i32, String)> = + [(1000, "https://example.com/spec"), (1002, "{"), (1000, "Specification"), (1002, "{"), (1071, "0"), (1002, "}"), (1002, "}")] + .iter() + .map(|(c, v)| (*c, v.to_string())) + .collect(); + assert_eq!(url, want); + } +} + +#[test] +fn dictionary_variables_xrecords_and_persistent_reactors_are_kept() { + let (_, a, b) = saves(&fixture()); + for o in [&a, &b] { + let (_, vars) = o.entry(o.root(), "AcDbVariableDictionary").expect("variable dictionary"); + let (kind, var) = o.entry(vars, "ACMEVAR").expect("ACMEVAR"); + assert_eq!((kind, s(var, 1)), ("DICTIONARYVAR", "var-value".into())); + // The line reacts to the ACME object, and the object to its dictionary and the line. + let line = o.handle("2A").expect("line").1; + let reactors = group(line, "ACAD_REACTORS"); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|x| x.0 == "ACME_OBJECT")), "line reactors {reactors:?}"); + let (_, nested) = o.entry(o.root(), "ACME_NESTED").expect("ACME_NESTED"); + let (_, obj) = o.entry(nested, "INNER").expect("INNER"); + assert_eq!(group(obj, "ACAD_REACTORS"), vec![h(nested), "2A".to_string()]); + assert_eq!(o.handle(&s(obj, 340)).map(|r| r.0), Some("LINE")); + } +} + +#[test] +fn every_reference_names_a_written_object() { + let (_, a, b) = saves(&fixture()); + assert_eq!(a.dangling(), Vec::::new()); + assert_eq!(b.dangling(), Vec::::new()); +} + +#[test] +fn saving_twice_writes_the_same_records() { + let (_, a, b) = saves(&fixture()); + assert_eq!(a.counts(), b.counts()); +} + +#[test] +fn erased_entities_take_their_extension_data_with_them() { + let mut d = read_dxf(fixture().as_bytes()).expect("read"); + let line = cadcraft_doc::Handle::parse_hex("2A").expect("handle"); + d.remove_entity(line).expect("erase the line"); + let o = Out::new(&write_dxf(&d)); + assert!(o.handle("2A").is_none()); + // Its extension dictionary is gone; the object that reacted to it no longer names it. + assert!(o.of("XRECORD").iter().all(|x| s(x, 1) != "entity-xdict-data")); + assert_eq!(o.dangling(), Vec::::new()); + let (_, nested) = o.entry(o.root(), "ACME_NESTED").expect("ACME_NESTED"); + let (_, obj) = o.entry(nested, "INNER").expect("INNER"); + assert_eq!(s(obj, 340), "0", "a pointer to an erased entity becomes null"); +} + +#[test] +fn a_new_colour_drops_the_colour_book_name() { + let mut d = read_dxf(fixture().as_bytes()).expect("read"); + let line = cadcraft_doc::Handle::parse_hex("2A").expect("handle"); + d.modify_entity(line, |e| e.common.color = cadcraft_color::Color::Index(3)).expect("recolour"); + if let Some(l) = d.layer_mut("WALLS") { + l.color = cadcraft_color::Color::Index(5); + } + let o = Out::new(&write_dxf(&d)); + assert_eq!(s(o.handle("2A").expect("line").1, 430), ""); + assert_eq!(s(o.named("LAYER", "WALLS"), 430), ""); +} + +/// Hostile variants of the fixture: ownership cycles, references to nothing, oversized and +/// duplicated data. Reading and saving them never panics and writes only valid references. +#[test] +fn hostile_raw_data_never_panics() { + let base = fixture(); + let variants: Vec = vec![ + // An object that owns itself, and two that own each other. + base.replace("5\n42\n102\n{ACAD_REACTORS\n330\n41\n", "5\n42\n102\n{ACAD_REACTORS\n330\n42\n") + .replace("5\n2D\n330\n2B\n", "5\n2D\n330\n2D\n") + .replace("5\n2B\n330\n2A\n", "5\n2B\n330\n2D\n"), + // References to handles that don't exist. + base.replace("340\n2A\n341\n11\n", "340\nFFFFF\n341\nZZ\n").replace("360\n2B\n", "360\nDEAD\n"), + // Duplicate handles: an object with an entity's handle, an entity with an object's. + base.replace("5\n40\n", "5\n2A\n").replace("5\n30\n", "5\n42\n"), + // Unterminated 102 groups and stray xdata. + base.replace("102\n}\n330\n1F\n", "330\n1F\n").replace("1001\nACME\n1000\nhello\n", "1000\nstray\n1001\n\n"), + // Huge numbers of xdata groups and dictionary entries. + base.replace("1000\nhello\n", &"1000\nhello\n".repeat(20_000)).replace("3\nINNER\n350\n42\n", &"3\nINNER\n350\n42\n".repeat(5_000)), + // A handle seed below every handle, non-hex handles and absurdly large ones. + base.replace("$HANDSEED\n5\n100\n", "$HANDSEED\n5\n1\n").replace("5\n44\n", "5\n-44\n").replace("5\n66\n", "5\nFFFFFFFFFFFFFFFF\n"), + ]; + for (i, v) in variants.iter().enumerate() { + assert_ne!(v, &base, "variant {i} changes the fixture"); + let Ok(d) = read_dxf(v.as_bytes()) else { continue }; + let first = write_dxf(&d); + let o = Out::new(&first); + assert_eq!(o.dangling(), Vec::::new(), "variant {i}"); + let again = read_dxf(first.as_bytes()).expect("reopen"); + let _ = write_dxf(&again); + } +} + +/// Through the DWG bridge (acadrust): what the DWG writer and reader keep of the same data. +/// Entities and objects of custom classes, xdata on table records and blocks, entities' colour +/// book names and reactors are not kept there (acadrust gaps); the rest is. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_keeps_extension_dictionaries_xrecords_and_entity_xdata() { + let via_dwg = |d: &Drawing| cadcraft_io::read(&cadcraft_io::write(d, "x.dwg").expect("write DWG"), "x.dwg").expect("read DWG"); + let d = via_dwg(&via_dwg(&read_dxf(fixture().as_bytes()).expect("read fixture"))); + let o = Out::new(&write_dxf(&d)); + assert_eq!(o.dangling(), Vec::::new()); + let line = o.handle("2A").expect("line").1; + assert_eq!(xdata(line, "ACME").get(..2), Some(&[(1000, "hello".to_string()), (1070, "42".to_string())][..])); + assert_eq!(xdata(line, "PE_URL").first(), Some(&(1000, "https://example.com/spec".to_string()))); + assert_eq!(s(line, 440), "33554559"); + let [xd] = group(line, "ACAD_XDICTIONARY").try_into().expect("the line's extension dictionary"); + let (_, dict) = o.handle(&xd).expect("dictionary"); + assert_eq!(o.entry(dict, "ACME_XREC").map(|x| s(x.1, 1)), Some("entity-xdict-data".into())); + assert_eq!(o.entry(o.root(), "ACME_DATA").map(|x| s(x.1, 1)), Some("top-level-xrecord".into())); + let (_, vars) = o.entry(o.root(), "AcDbVariableDictionary").expect("variable dictionary"); + assert_eq!(o.entry(vars, "ACMEVAR").map(|x| (x.0, s(x.1, 1))), Some(("DICTIONARYVAR", "var-value".into()))); + let table = o.of("TABLE").into_iter().find(|t| s(t, 2) == "LAYER").expect("LAYER table"); + assert_eq!(group(table, "ACAD_XDICTIONARY").len(), 1, "layer states"); + assert_eq!(s(o.named("LAYER", "WALLS"), 430), "RAL$RAL 3020"); + assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf")); + assert!(d.ucss.iter().any(|u| u.name == "TILTED")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ee023e9..75d88c38 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr | VIEWPORT | yes | yes; frozen-layer lists kept (#150) | VP layer colour overrides lost (#99, PR #197) | | IMAGE | partial (no IMAGEDEF, so the file path is empty) | **dropped** | | | WIPEOUT | yes | yes (#147, fixes #79) | | -| MLINE, REGION, 3DSOLID, BODY, SURFACE, MESH, TOLERANCE, SHAPE, HELIX, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | **dropped on save** | the most serious file gap: opening and saving someone's drawing deletes what we don't model | +| MLINE, REGION, 3DSOLID, BODY, SURFACE, MESH, TOLERANCE, SHAPE, HELIX, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); **dropped by DWG save** (acadrust) | only their layer follows edits | | Header variables | all read | 36 written | AutoCAD writes about 280 | | Tables: LAYER (+ transparency, description), LTYPE, STYLE, DIMSTYLE, BLOCK_RECORD | yes | yes | | | Tables: VPORT, VIEW, UCS, APPID | **not read** | VPORT one record; VIEW and UCS empty; APPID yes | named views and UCSs lost | diff --git a/docs/gaps.md b/docs/gaps.md index 03f02d8c..599254d2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -13,7 +13,7 @@ do them first. | # | Gap | Why it ranks here | Est. | Doc | |---|---|---|---|---| -| 1 | **[alpha blocker]** **Saving drops whatever we don't model** (Unknown entities, IMAGE, layer states, block descriptions, true colours, MULTILEADER written as LEADER+MTEXT, groups, named views/UCS, foreign xdata) | Opening a colleague's DWG and saving it silently deletes content: the one thing a CAD user can't forgive | 15–25 | [file formats](file-format-parity.md) | +| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528; through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; MULTILEADER is still written as LEADER+MTEXT) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Tracking leftovers** (object snap tracking, Extension, Parallel and Apparent Intersection landed, #379): polar angles relative to the last segment, PolarSnap, temporary tracking point | Small now; ranks lower at the next review | 3–5 | [ui](ui-parity.md#tracking-polar-ortho-grid-and-snap--70) | | 4 | **No code pages when reading pre-2007 files** (saving DWG/DXF at 2000–2018 and binary DXF landed, #421) | Pre-2007 CJK/Cyrillic files are garbled | 3–5 | [file formats](file-format-parity.md) | @@ -77,7 +77,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | # | Gap | User impact | Est. | |---|---|---|---| -| FF1 | **[alpha blocker]** Round-trip what we don't model: keep Unknown entities and objects, foreign xdata, extension dictionaries; write IMAGE (with IMAGEDEF), VIEW/UCS/VPORT tables, groups, layer states, block descriptions, true colours (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients done (#147, #162) | High | 12–20 | +| FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data | High | 6–10 | | FF2 | **[alpha blocker]** Native MULTILEADER objects (read and write) | High | 6–10 | | FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 | | FF4 | Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) when reading pre-2007 files | Medium-high (Asia, Eastern Europe) | 3–5 |