diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 965b348e4..193c2490b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -6,6 +6,7 @@ author, source and license. Library code and original assets are under the repos | File | Author | Source | License | |---|---|---|---| +| `licenses/dwg/acadrust-MPL-2.0.txt`, `licenses/dwg/acadrust-THIRD_PARTY_NOTICES.md` | `acadrust` contributors; ACadSharp authors for the noted ported routines | [acadrust 0.6.6](https://github.com/hakanaktt/acadrust), used unmodified as `craft-dwg`'s optional native dependency | MPL-2.0; embedded ACadSharp notice is MIT; see both retained files | | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | | `crates/font-store/index/sources.json` (unmodified) | craft-font-index contributors | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index f2fbe2d03..c10502ccb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acadrust" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8923c99ea1f151cb7fc17ce2fc14f8b1e330fa22611e535602d985c0cc3c89a2" +dependencies = [ + "anyhow", + "bitflags 2.13.2", + "byteorder", + "encoding_rs", + "flate2", + "foldhash 0.2.0", + "indexmap", + "itoa", + "memmap2", + "nom 7.1.3", + "oem_cp", + "once_cell", + "rayon", + "ryu", + "thiserror 2.0.21", + "web-time", +] + [[package]] name = "accesskit" version = "0.25.1" @@ -1399,6 +1423,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-dwg" +version = "0.1.0" +dependencies = [ + "acadrust", + "log", +] + [[package]] name = "craft-dxf" version = "0.1.0" @@ -4358,7 +4390,7 @@ dependencies = [ "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -5577,6 +5609,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5750,6 +5788,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -6182,6 +6230,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "oem_cp" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6499,7 +6556,7 @@ dependencies = [ "log", "md-5 0.10.6", "memchr", - "nom", + "nom 8.0.0", "num-bigint-dig", "num-traits", "num_enum", @@ -6525,6 +6582,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -6532,7 +6598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -6542,7 +6608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -6552,12 +6618,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" diff --git a/Cargo.toml b/Cargo.toml index ac6f09673..3839364a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] +acadrust = "=0.6.6" +log = "0.4" # internal: add each crate here as `craft- = { path = "crates/" }` craft-platform = { path = "crates/platform" } craft-aep = { path = "crates/aep" } @@ -53,6 +55,7 @@ vectorcraft-pathops = { git = "https://github.com/storytold/vectorcraft.git", re craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-dpx = { path = "crates/dpx" } +craft-dwg = { path = "crates/dwg" } craft-ecproj-format = { path = "crates/ecproj-format" } craft-figma-rest = { path = "crates/figma-rest" } craft-exr = { path = "crates/exr" } diff --git a/NOTICE b/NOTICE index c6583cc70..4e70405df 100644 --- a/NOTICE +++ b/NOTICE @@ -5,6 +5,17 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio Third-party material +Optional DWG bridge + +`craft-dwg` uses `acadrust` 0.6.6 unmodified as a separate dependency for DWG reading and writing. +It is licensed under the Mozilla Public License 2.0. The license text and upstream third-party +notices are retained in `licenses/dwg/`; the crate is available from crates.io and +https://github.com/hakanaktt/acadrust. + +The byte-level bridge source was moved from CADCraft `crates/dwg` at revision +`664837df8925f932e90a7ae37c58d2f54e7a1320`. The bridge itself is MIT OR Apache-2.0; its only +MPL-2.0 component is the unmodified `acadrust` dependency. + This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. diff --git a/README.md b/README.md index f6e0a4443..aa47358da 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,9 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit pages and version detection, and written back; and `model`, the drawing above the tags (header variables, tables, blocks, layouts, entities) and its writer (R12 to 2018), merged from CADCraft's and VectorCraft's readers. Bounded on hostile input. Mapping the model onto a document stays in each app. +- [`craft-dwg`](crates/dwg/README.md): bounded byte-level DWG↔DXF conversion with diagnostics, + capped output and an optional child-process worker; the `acadrust` dependency remains isolated + behind bytes and is unavailable on wasm32 and FreeBSD/aarch64. - [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj` project files: `savedBy`, the schema version and its check, and the file object with the project and its saved view as type parameters. The project model stays in EffectCraft. diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml new file mode 100644 index 000000000..d2bc8ffff --- /dev/null +++ b/crates/dwg/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-dwg" +description = "Bounded DWG↔DXF byte bridge with an optional child-process worker" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +# acadrust is used unmodified and only on targets supported by its 0.6.6 platform APIs. +[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies] +acadrust = { workspace = true } +log = { workspace = true } diff --git a/crates/dwg/README.md b/crates/dwg/README.md new file mode 100644 index 000000000..ed52bdcfc --- /dev/null +++ b/crates/dwg/README.md @@ -0,0 +1,18 @@ +# craft-dwg + +A byte-level bridge between DWG and DXF. It does not expose `acadrust` types: callers pass bytes, +choose limits, and receive converted bytes plus notes about data the conversion could not preserve. +Application document mapping remains with the caller. + +The default limits reject DWG inputs over 512 MiB and stop DXF output over 1 GiB. The bridge +also catches panics from the dependency and validates its conversion diagnostics. Because a small +hostile file can declare very large internal sections, size checks alone cannot prevent an +allocation failure inside the decoder. On native applications, call `use_own_worker()` during +startup and call `serve_if_asked()` before normal argument handling. Subsequent `decode` and +`encode_*` calls then run in a child process with a 30-minute timeout; a crash or stack overflow +is contained to that child. The worker has no operating-system memory limit yet. Without a +configured worker, conversions run in the current process. + +The bridge is not built for `wasm32` or FreeBSD/aarch64. Those targets retain the public API and +return a clear unsupported-build error. The `acadrust` 0.6.6 dependency is used unmodified under +MPL-2.0; its license and third-party notices are in `licenses/dwg/` at the workspace root. diff --git a/crates/dwg/build.rs b/crates/dwg/build.rs new file mode 100644 index 000000000..e97782ab4 --- /dev/null +++ b/crates/dwg/build.rs @@ -0,0 +1,12 @@ +//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions +//! there return an error that says to use DXF. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)"); + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") { + println!("cargo::rustc-cfg=no_dwg_bridge"); + } +} diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs new file mode 100644 index 000000000..2db7afeae --- /dev/null +++ b/crates/dwg/src/lib.rs @@ -0,0 +1,2096 @@ +//! DWG support through a DXF bridge. +//! +//! DWG files are read with the `acadrust` crate (MPL-2.0, used unmodified as a dependency) and +//! converted to DXF bytes for the caller's DXF reader; saving goes the other way. Keeping the +//! dependency behind this byte-level API prevents its document types from leaking to consumers. +//! Native targets only (the dependency memory-maps files). +//! +//! # Guardrails around the third-party decoder +//! +//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile) +//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the +//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this +//! crate guards it from the outside: +//! +//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`] +//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while +//! it is written (`CappedWriter`). +//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of +//! crashing. +//! +//! These bound the input- and output-size variants. A small file that declares a huge internal +//! table or section can still make the dependency allocate before any size is known, so the app +//! runs both directions in a child process ([`worker`]): what that kills is the child. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +/// DWG version codes we can write. +pub const VERSIONS: &[(&str, &str)] = + &[("AC1015", "2000"), ("AC1018", "2004"), ("AC1021", "2007"), ("AC1024", "2010"), ("AC1027", "2013"), ("AC1032", "2018")]; + +/// True when the bytes look like a DWG file (`AC10xx` magic). +pub fn is_dwg(bytes: &[u8]) -> bool { + bytes.len() > 6 && bytes.starts_with(b"AC10") && bytes.get(4..6).is_some_and(|v| v.iter().all(u8::is_ascii_digit)) +} + +/// The DWG version code of a file (e.g. "AC1032"). +pub fn version(bytes: &[u8]) -> Option { + is_dwg(bytes).then(|| String::from_utf8_lossy(bytes.get(0..6).unwrap_or_default()).to_string()) +} + +/// Size limits for reading a DWG file. DWG is compressed and the DXF text it converts to is +/// several times larger (about 6x in our tests: a 10 MB DWG becomes about 60 MB of DXF), so +/// the limits are generous for real drawings. They exist because a damaged file, or one the reader +/// misreads, can make the DWG reader produce a drawing thousands of times larger than the file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Largest DWG file read at all (checked before reading starts). + pub max_dwg_bytes: usize, + /// Largest DXF rendition of a DWG (checked while it is written). + pub max_dxf_bytes: usize, +} + +impl Limits { + /// 512 MB of DWG, 1 GB of DXF (more than the DXF reader's group-code limit fills). + pub const DEFAULT: Limits = Limits { max_dwg_bytes: 512 << 20, max_dxf_bytes: 1 << 30 }; +} + +/// A byte count in megabytes for messages ("9.9 MB", "1024 MB"). +#[cfg_attr(no_dwg_bridge, allow(dead_code))] +fn mb(n: u64) -> String { + let m = n as f64 / f64::from(1u32 << 20); + if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") } +} + +#[cfg(not(no_dwg_bridge))] +pub mod worker; +#[cfg(not(no_dwg_bridge))] +pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker}; +#[cfg(not(no_dwg_bridge))] +mod proxy_transport; + +#[cfg(not(no_dwg_bridge))] +mod native { + use std::io::{Cursor, Write}; + use std::panic::AssertUnwindSafe; + + use super::{Limits, mb}; + + /// Read DWG bytes and return an ASCII DXF rendition, within [`Limits::DEFAULT`]. + pub fn dwg_to_dxf(bytes: &[u8]) -> Result, String> { + dwg_to_dxf_with(bytes, Limits::DEFAULT) + } + + /// [`dwg_to_dxf`] with explicit limits. Both are checked before the expensive step they + /// guard: the input size before the DWG reader runs, the DXF size while it is written, so a + /// runaway conversion stops at the limit instead of filling memory first. + pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result, String> { + dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf) + } + + /// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in + /// failsafe mode: an object record it can't decode is skipped instead of failing the whole + /// file, and the notes say how many were skipped and of which types, so a partial read never + /// passes for a complete one. + pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + if bytes.len() > limits.max_dwg_bytes { + return Err(format!( + "DWG: the file is {}, larger than the {} accepted by this bridge", + mb(bytes.len() as u64), + mb(limits.max_dwg_bytes as u64) + )); + } + let outcome = std::panic::catch_unwind(|| { + acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats() + }) + .map_err(|_| "the DWG reader failed on this file".to_string())? + .map_err(|e| format!("DWG: {e}"))?; + // Failsafe skips records, not whole files: when the objects stream itself couldn't be + // read (a damaged header or section map, or not a DWG at all), nothing was read. + if !outcome.stats.record_stream_read { + let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string()); + return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string()))); + } + 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())?, + ); + // After application_objects, so the proxy objects it makes are numbered as the DXF's. + notes.extend( + guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy 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") }; + notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out")); + } + let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false }; + let written = std::panic::catch_unwind(AssertUnwindSafe(|| { + acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?; + mtext_contents(&doc, &mut out) + })) + .map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?; + if out.exceeded { + return Err(format!( + "DWG: converting this {} file produced more than {} of drawing data (the limit); \ + the file is probably damaged, or uses objects the DWG reader misreads", + mb(bytes.len() as u64), + mb(limits.max_dxf_bytes as u64) + )); + } + written.map_err(|e| format!("DWG→DXF: {e}"))?; + Ok((add_header_vars(out.buf, &doc.header), notes)) + } + + /// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already + /// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents + /// from the native values instead, using its lossless string writer. All other groups, + /// including embedded-object data, remain byte-for-byte as the DXF writer emitted them. + fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> { + use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter}; + let contents: std::collections::HashMap<_, _> = doc + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())), + _ => None, + }) + .collect(); + if contents.is_empty() { + return Ok(()); + } + let dxf = std::mem::take(&mut out.buf); + let mut lines = dxf.split_inclusive(|b| *b == b'\n'); + let (mut mtext, mut text, mut written) = (false, None, false); + while let Some(code) = lines.next() { + let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?; + match code.trim_ascii() { + b"0" => { + mtext = value.trim_ascii() == b"MTEXT"; + text = None; + written = false; + } + b"5" if mtext => { + text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h)); + } + b"101" => mtext = false, + b"1" | b"3" if mtext && text.is_some() => { + if let Some(&text) = text + && !written + { + let mut writer = DxfTextWriter::new(&mut *out); + if doc.version < acadrust::DxfVersion::AC1021 + && let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page) + { + writer.set_encoding(encoding); + } + // Each character can expand to two caret-escape characters. Keep + // those pairs together and each group within 250 characters. + let mut rest = text; + while let Some((end, _)) = rest.char_indices().nth(125) { + let (chunk, tail) = rest.split_at(end); + writer.write_xrecord_string(3, chunk)?; + rest = tail; + } + writer.write_xrecord_string(1, rest)?; + written = true; + } + continue; + } + _ => {} + } + out.write_all(code)?; + out.write_all(value)?; + } + Ok(()) + } + + #[cfg(test)] + #[test] + fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> { + for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] { + let mut doc = acadrust::CadDocument::new(); + doc.version = version; + doc.header.code_page = "ANSI_1252".into(); + let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO); + t.common.handle = acadrust::Handle::new(0x10); + doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?; + // Only the MTEXT's own 1/3 groups change. The embedded object's groups and the + // following TEXT record must survive byte-for-byte, including their spaces. + let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n"; + let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n"; + let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat(); + let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false }; + mtext_contents(&doc, &mut out)?; + let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() }; + assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat()); + let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false }; + assert!(mtext_contents(&doc, &mut limited).is_err()); + assert!(limited.exceeded); + } + Ok(()) + } + + /// Most record types named in the skipped-records note. + const NOTE_TYPES: usize = 6; + + /// The note on object records a failsafe read skipped: how many, and how many of each type + /// (most first), or nothing when the read was complete. + pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec { + let skipped = stats.skipped_source_records; + if skipped == 0 { + return Vec::new(); + } + let label = |t: Option<&str>| match t.map(str::parse::) { + Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string), + Some(Err(_)) => t.unwrap_or_default().to_string(), + None => "unknown".to_string(), + }; + let mut by_type: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) { + *by_type.entry(label(d.record_type.as_deref())).or_default() += 1; + } + let mut types: Vec<(String, usize)> = by_type.into_iter().collect(); + types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + let mut list: Vec = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect(); + if types.len() > NOTE_TYPES { + list.push(format!("{} more types", types.len() - NOTE_TYPES)); + } + let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() }; + let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" }; + let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") }; + let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) }; + vec![format!( + "DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \ + saving over the original file would lose {them}" + )] + } + + /// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are + /// numbered per file). + fn type_name(code: i16) -> Option<&'static str> { + Some(match code { + 1 => "TEXT", + 2 => "ATTRIB", + 3 => "ATTDEF", + 4 => "BLOCK", + 5 => "ENDBLK", + 6 => "SEQEND", + 7 => "INSERT", + 8 => "MINSERT", + 10..=14 => "VERTEX", + 15 | 16 | 29 | 30 => "POLYLINE", + 17 => "ARC", + 18 => "CIRCLE", + 19 => "LINE", + 20..=26 => "DIMENSION", + 27 => "POINT", + 28 => "3DFACE", + 31 => "SOLID", + 32 => "TRACE", + 33 => "SHAPE", + 34 => "VIEWPORT", + 35 => "ELLIPSE", + 36 => "SPLINE", + 37 => "REGION", + 38 => "3DSOLID", + 39 => "BODY", + 40 => "RAY", + 41 => "XLINE", + 42 => "DICTIONARY", + 43 => "OLEFRAME", + 44 => "MTEXT", + 45 => "LEADER", + 46 => "TOLERANCE", + 47 => "MLINE", + 48 => "BLOCK_CONTROL", + 49 => "BLOCK_RECORD", + 50 => "LAYER_CONTROL", + 51 => "LAYER", + 52 => "STYLE_CONTROL", + 53 => "STYLE", + 56 => "LTYPE_CONTROL", + 57 => "LTYPE", + 60 => "VIEW_CONTROL", + 61 => "VIEW", + 62 => "UCS_CONTROL", + 63 => "UCS", + 64 => "VPORT_CONTROL", + 65 => "VPORT", + 66 => "APPID_CONTROL", + 67 => "APPID", + 68 => "DIMSTYLE_CONTROL", + 69 => "DIMSTYLE", + 70 => "VX_CONTROL", + 71 => "VX_TABLE_RECORD", + 72 => "GROUP", + 73 => "MLINESTYLE", + 74 => "OLE2FRAME", + 77 => "LWPOLYLINE", + 78 => "HATCH", + 79 => "XRECORD", + 80 => "ACDBPLACEHOLDER", + 82 => "LAYOUT", + _ => return None, + }) + } + + /// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's + /// merged cells into the table's `merged_ranges` only, while its DXF writer writes each cell's + /// own merge groups (173, 175, 176), so without this the merges would be lost. Tables whose + /// cells already carry merges are left alone. Each range's first cell gets the span; the + /// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does. + fn table_merges(doc: &mut acadrust::CadDocument) { + table_merges_within(doc, MAX_MERGE_CELLS); + } + + /// The most table cells [`table_merges`] visits in one document: each range is capped, but + /// can cover rows × cols cells, so the total is bounded too. + const MAX_MERGE_CELLS: usize = 1 << 24; + + /// A table cell's own text height only when the file says it overrides the table style's. + /// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a + /// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an + /// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the + /// content's format override bit 128; without either the height is cleared (0 is no + /// override), so the table style's height applies. + fn table_cell_heights(doc: &mut acadrust::CadDocument) { + use acadrust::entities::table::CellStylePropertyFlags; + const CONTENT_TEXT_HEIGHT: i32 = 128; + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Table(t) = e else { continue }; + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + // The first content's own height (group 144) when its format override sets bit 128. + // CADCraft keeps one height for the cell's text, so a later content's override + // must not stand in for the first content's. + let content_height = cell + .contents + .first() + .filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0) + .map(|c| c.text_height) + .filter(|h| h.is_finite() && *h > 0.0); + if let Some(h) = content_height { + // CADCraft reads only the style's height (group 140), so carry it there. + cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h; + continue; + } + // A style overrides through its property flags or, in newer files, its override + // flags; anything else is the writer's default and is cleared. + if let Some(style) = cell.style.as_mut() + && !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT) + && style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0 + { + style.text_height = 0.0; + } + } + } + } + + /// The fix-ups made to a document after the DWG read, returning how many orphaned DGN + /// components were dropped. The document comes from a third-party reader and is hostile. + fn post_process(doc: &mut acadrust::CadDocument) -> usize { + super::fail_in_test("post_process"); + current_style_names(doc); + table_merges(doc); + table_cell_heights(doc); + table_style_rows(doc, false); + keep_sort_handles(doc); + drop_orphaned_dgn_components(doc) + } + + /// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way). + fn guarded(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result { + 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. + pub(super) 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 this bridge can't preserve 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 { + let mut left = budget; + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Table(t) = e else { continue }; + if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) { + continue; + } + let (rows, cols) = (t.rows.len(), t.columns.len()); + // A real table has at most one merge per cell; more ranges than that come from a + // damaged file and would only make this rows × cols × ranges. + let max_ranges = rows.saturating_mul(cols).min(1 << 16); + for r in t.merged_ranges.iter().take(max_ranges).cloned().collect::>() { + // Clamp to the table: the ranges come from the file. + let (bottom, right) = (r.bottom_row.min(rows.saturating_sub(1)), r.right_col.min(cols.saturating_sub(1))); + if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) { + continue; + } + let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1); + if area > left { + continue; + } + left -= area; + let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX); + for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) { + for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) { + cell.merged = 1; + let first = (ri, ci) == (r.top_row, r.left_col); + cell.merge_width = if first { span(right - r.left_col) } else { 0 }; + cell.merge_height = if first { span(bottom - r.top_row) } else { 0 }; + } + } + } + } + budget - left + } + + /// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF + /// writer renumbers them past every entity handle, but an entity left out of a table sorts by + /// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving + /// the table its codes verbatim makes the writer emit them unchanged. + fn keep_sort_handles(doc: &mut acadrust::CadDocument) { + use acadrust::objects::ObjectType; + let version = doc.version; + for o in doc.objects.values_mut() { + let ObjectType::SortEntitiesTable(t) = o else { continue }; + let hex = |h: acadrust::types::Handle| format!("{:X}", h.value()); + let mut codes = + vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))]; + for e in t.entries() { + codes.push((331, hex(e.entity_handle))); + codes.push((5, hex(e.sort_handle))); + } + t.raw_dxf_codes = Some(codes); + t.raw_dxf_version = Some(version); + } + } + + /// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps + /// it. 2010+ (AC1024+) DWG stores it only in the style's flags (title 1, header 2), while + /// acadrust's DXF reader and writer use the `title_suppressed`/`header_suppressed` fields + /// (groups 280, 281). `to_flags` copies the fields into the flags before a DWG is written; + /// otherwise the flags are copied into the fields after one is read. Older versions store + /// both and are left alone. + fn table_style_rows(doc: &mut acadrust::CadDocument, to_flags: bool) { + use acadrust::objects::{ObjectType, TableStyleFlags}; + if doc.version < acadrust::DxfVersion::AC1024 { + return; + } + for o in doc.objects.values_mut() { + let ObjectType::TableStyle(s) = o else { continue }; + for (bit, suppressed) in + [(TableStyleFlags::TITLE_SUPPRESSED, &mut s.title_suppressed), (TableStyleFlags::HEADER_SUPPRESSED, &mut s.header_suppressed)] + { + if to_flags { + s.flags.set(bit, *suppressed); + } else { + *suppressed = s.flags.contains(bit); + } + } + } + } + + /// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification, + /// common dimension data), which acadrust keeps no copy of from DXF: it is set here as + /// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128, + /// acadrust 0.6.6 reads and writes itself. + fn dimension_flags(doc: &mut acadrust::CadDocument) { + for e in doc.entities_mut() { + let acadrust::entities::EntityType::Dimension(d) = e else { continue }; + d.base_mut().dwg_flags_byte |= 0x02; + } + } + + /// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses + /// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of + /// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE + /// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053 + /// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file + /// didn't open at all. No definition that was read refers to them (a definition the reader + /// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing + /// visible is lost. + /// Every component a definition reaches (directly, or through compound and point + /// components) is kept, as are the definitions; dictionary entries that named a dropped + /// component go with it. Returns how many were left out. + fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize { + use acadrust::objects::{DgnLineStyleData, ObjectType}; + use std::collections::{HashMap, HashSet}; + // Each component's references, and the definitions' root components to start from. + let mut refs: HashMap> = HashMap::new(); + let mut stack: Vec = Vec::new(); + for (h, o) in &doc.objects { + let ObjectType::DgnLineStyle(ls) = o else { continue }; + match &ls.data { + DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component), + DgnLineStyleData::Component { component, .. } => { + refs.insert(*h, component.references()); + } + DgnLineStyleData::Registered { .. } => {} + } + } + if refs.is_empty() { + return 0; + } + // Each component is visited once, so cycles and shared subtrees end. + let mut used: HashSet = HashSet::new(); + while let Some(h) = stack.pop() { + if let Some(r) = refs.get(&h) + && used.insert(h) + { + stack.extend(r.iter().copied()); + } + } + let orphans: HashSet = refs.into_keys().filter(|h| !used.contains(h)).collect(); + if orphans.is_empty() { + return 0; + } + doc.objects.retain(|h, _| !orphans.contains(h)); + doc.dgn_ls_components.retain(|h, _| !orphans.contains(h)); + for o in doc.objects.values_mut() { + let ObjectType::Dictionary(d) = o else { continue }; + if d.entries.iter().any(|(_, e)| orphans.contains(e)) { + d.entries.retain(|(_, e)| !orphans.contains(e)); + let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect(); + d.hard_owner_entries.retain(|k| keys.contains(k.as_str())); + } + } + orphans.len() + } + + /// Leave out what acadrust can't write as this DWG version: other programs' custom entities + /// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file + /// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types + /// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many + /// records of each DXF type were left out, so a save can say so. + fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten { + let version = doc.version; + let mut dropped = super::Unwritten::new(); + let mut count = |name: &str| { + let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0); + *n = n.saturating_add(1); + }; + let unwritable: Vec = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Unknown(u) + if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => + { + count(&u.dxf_name); + Some(u.common.handle) + } + _ => None, + }) + .collect(); + for h in &unwritable { + doc.remove_entity(*h); + } + let objects: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. } + if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 => + { + count(type_name); + Some(*h) + } + _ => None, + }) + .collect(); + for h in &objects { + doc.objects.remove(h); + } + dropped + } + + /// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of + /// the HEADER section: (name, group code, value). + fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] { + [ + // DWG holds radians; `$ANGBASE` is in degrees. + ("$ANGBASE", 50, h.angle_base.to_degrees().to_string()), + ("$ANGDIR", 70, h.angle_direction.to_string()), + ("$FILLETRAD", 40, h.fillet_radius.to_string()), + ("$CHAMFERA", 40, h.chamfer_distance_a.to_string()), + ("$CHAMFERB", 40, h.chamfer_distance_b.to_string()), + ("$ELEVATION", 40, h.elevation.to_string()), + ("$THICKNESS", 40, h.thickness.to_string()), + ] + } + + /// Add the [`unwritten_header_vars`] to the HEADER section of acadrust's DXF text, before its + /// ENDSEC; a variable the writer already wrote is left alone. Text of another layout than + /// acadrust's (` 0\r\nSECTION\r\n 2\r\nHEADER\r\n` … ` 0\r\nENDSEC\r\n`) is returned unchanged. + fn add_header_vars(mut dxf: Vec, h: &acadrust::document::HeaderVariables) -> Vec { + const START: &[u8] = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"; + const END: &[u8] = b"\r\n 0\r\nENDSEC\r\n"; + if !dxf.starts_with(START) { + return dxf; + } + let Some(end) = dxf.windows(END.len()).position(|w| w == END).map(|p| p + 2) else { return dxf }; + let section = dxf.get(..end).unwrap_or_default(); + let mut add = String::new(); + for (name, code, value) in unwritten_header_vars(h) { + let written = section.windows(name.len() + 2).any(|w| w.starts_with(name.as_bytes()) && w.ends_with(b"\r\n")); + if !written && value.parse::().is_ok_and(f64::is_finite) { + add.push_str(&format!(" 9\r\n{name}\r\n{code:>3}\r\n{value}\r\n")); + } + } + dxf.splice(end..end, add.into_bytes()); + dxf + } + + /// Collects the DXF text and fails the write once it would grow past `limit`. + struct CappedWriter { + buf: Vec, + limit: usize, + exceeded: bool, + } + + impl Write for CappedWriter { + fn write(&mut self, b: &[u8]) -> std::io::Result { + // Sticky: once over, every later write fails too. + if self.exceeded || self.buf.len().saturating_add(b.len()) > self.limit { + self.exceeded = true; + return Err(std::io::Error::other("DXF size limit reached")); + } + self.buf.extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Oldest DWG version written; older DXF input is written as this version. + const MIN_DWG_VERSION: acadrust::DxfVersion = acadrust::DxfVersion::AC1018; + + /// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004 + /// (AC1018). + pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { + convert(dxf, None, None).map(|(dwg, _)| dwg) + } + + /// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"), + /// whatever version the DXF declares. + pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg) + } + + /// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each + /// DXF type it can't write (other programs' custom entities and objects that the DWG reader + /// didn't read from a DWG of this version), empty when the DWG keeps everything. + pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { + convert(dxf, Some(writable(version)?), None) + } + + /// Like [`dxf_to_dwg_as`], for a DXF that declares an older version than the groups it carries + /// over from another file. CADCraft's R2000 DWG bridge holds the objects it keeps without + /// modelling them (render settings, fields, associative and dynamic block data) as they were + /// read from a file of version `kept` ("AC1032"), and acadrust reads several of those + /// differently by version: read as R2000, an AC1032 file's mental ray render settings came out + /// a class version too high, for which the ODA rejects the whole DWG, and a field lost its + /// cached value. So the DXF is read as `kept` declares. A `kept` version that is unknown, or + /// not newer than the DXF's own, changes nothing. + pub fn dxf_to_dwg_kept_as(dxf: &[u8], version: &str, kept: &str) -> Result, String> { + dxf_to_dwg_kept_noted(dxf, version, kept).map(|(dwg, _)| dwg) + } + + /// [`dxf_to_dwg_kept_as`], also returning what the DWG writer left out, as + /// [`dxf_to_dwg_noted`] does. + pub fn dxf_to_dwg_kept_noted(dxf: &[u8], version: &str, kept: &str) -> Result<(Vec, super::Unwritten), String> { + let kept = acadrust::DxfVersion::from_version_string(kept.trim().to_ascii_uppercase().as_str()); + let kept = (kept != acadrust::DxfVersion::Unknown).then_some(kept); + convert(dxf, Some(writable(version)?), kept) + } + + /// The DWG version for a code from [`super::VERSIONS`]. + fn writable(version: &str) -> Result { + Ok(match version.trim().to_ascii_uppercase().as_str() { + "AC1015" => acadrust::DxfVersion::AC1015, + "AC1018" => acadrust::DxfVersion::AC1018, + "AC1021" => acadrust::DxfVersion::AC1021, + "AC1024" => acadrust::DxfVersion::AC1024, + "AC1027" => acadrust::DxfVersion::AC1027, + "AC1032" => acadrust::DxfVersion::AC1032, + _ => return Err(super::unsupported(version)), + }) + } + + /// The ASCII DXF with its `$ACADVER` set to `kept` when that is newer than the version it + /// declares, or `None` to read it as it is (binary, no `$ACADVER`, or non-ASCII text without a + /// `$DWGCODEPAGE` to decode it by). From AutoCAD 2007 (AC1021) a DXF is UTF-8, so other bytes + /// are first decoded with the code page the DXF declares; `\U+XXXX` escapes read the same in + /// every version, and CADCraft's R2000 DXF is ASCII with such escapes. + pub(super) fn declare_version(dxf: &[u8], kept: acadrust::DxfVersion) -> Option> { + if dxf.starts_with(b"AutoCAD Binary DXF") { + return None; + } + let (_, declared) = header_value(dxf, b"$ACADVER")?; + let declared = acadrust::DxfVersion::from_version_string(std::str::from_utf8(declared).ok()?); + if declared >= kept { + return None; + } + let text: std::borrow::Cow<'_, [u8]> = if dxf.is_ascii() || kept < acadrust::DxfVersion::AC1021 { + std::borrow::Cow::Borrowed(dxf) + } else { + // As acadrust decodes an older DXF: by its code page, Windows-1252 for one it doesn't + // know; a code page that names UTF-8 needs no decoding. + let (_, code_page) = header_value(dxf, b"$DWGCODEPAGE")?; + match acadrust::io::dxf::code_page::encoding_from_code_page(std::str::from_utf8(code_page).ok()?) { + Some(encoding) => { + let (text, errors) = encoding.decode_without_bom_handling(dxf); + if errors { + return None; + } + std::borrow::Cow::Owned(text.into_owned().into_bytes()) + } + None => std::borrow::Cow::Borrowed(std::str::from_utf8(dxf).ok()?.as_bytes()), + } + }; + let (at, value) = header_value(&text, b"$ACADVER")?; + let code = kept.to_string(); + let mut out = Vec::with_capacity(text.len().saturating_add(code.len())); + out.extend_from_slice(text.get(..at)?); + out.extend_from_slice(code.as_bytes()); + out.extend_from_slice(text.get(at.checked_add(value.len())?..)?); + Some(out) + } + + /// The byte offset and the trimmed value of header variable `name` in an ASCII DXF: the value + /// of the group that follows the `9` group naming it. The file is read as (group code, + /// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end + /// of the HEADER section: a large input without one costs time, not memory (a line index of + /// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`. + pub(super) fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> { + let mut offset = 0usize; + let mut lines = dxf.split(|b| *b == b'\n').map(|line| { + let at = offset.saturating_add(line.len() - line.trim_ascii_start().len()); + offset = offset.saturating_add(line.len()).saturating_add(1); + (at, line.trim_ascii()) + }); + while let (Some((_, code)), Some((_, value))) = (lines.next(), lines.next()) { + if code == b"0" && value == b"ENDSEC" { + return None; + } + if code == b"9" && value == name { + let (_, code) = lines.next()?; + let (at, value) = lines.next()?; + return (code != b"0" && code != b"9").then_some((at, value)); + } + } + None + } + + /// The current dimension and text styles' names from their handles: a DWG stores the + /// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG + /// lost its current dimension style (#1051). + fn current_style_names(doc: &mut acadrust::CadDocument) { + let h = doc.header.current_dimstyle_handle; + if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) { + doc.header.current_dimstyle_name = n; + } + let h = doc.header.current_text_style_handle; + if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) { + doc.header.current_text_style_name = n; + } + } + + /// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the + /// handle read with it needn't be the style's in the document, in which case the DWG writer + /// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051). + fn current_dimstyle(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_dimstyle_name.clone(); + if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) { + doc.header.current_dimstyle_handle = h; + } + } + + /// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]: + /// the handle read with the DXF needn't be the style's in the document, in which case the DWG + /// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650). + fn current_text_style(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_text_style_name.clone(); + if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) { + doc.header.current_text_style_handle = h; + } + } + + /// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number + /// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for + /// one without a CLASSES section. `None` for a binary DXF, whose classes this doesn't read: + /// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public + /// conversion functions comes here. + pub(super) fn dxf_class_names(dxf: &[u8]) -> Option> { + let pairs = super::proxy_transport::text_pairs(dxf)?; + let mut names = Vec::new(); + let (mut section, mut in_classes, mut in_class) = (false, false, false); + for pair in pairs { + let (code, value) = pair?; + match (code, value.as_str()) { + (0, "ENDSEC") if in_classes => break, + (0, "SECTION") => section = true, + (2, name) if section => { + in_classes = name == "CLASSES"; + section = false; + } + (0, kind) if in_classes => { + in_class = kind == "CLASS"; + // Even an unnamed record keeps its place in the source class table. + if in_class { + names.push(String::new()); + } + } + (1, value) if in_class => { + if let Some(last) = names.last_mut() { + *last = value.to_string(); + } + in_class = false; + } + _ => section = false, + } + } + Some(names) + } + + /// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy + /// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's + /// reader puts its own default classes first and numbers the DXF's after them, so the DXF + /// number named another class in the DWG: a 2018 drawing's piping proxies became + /// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A + /// proxy whose number names no class of the DXF is left out: kept as it is, its number would + /// name some other class of the DWG, for the same reason. + /// + /// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a + /// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses + /// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the + /// proxies out instead, as it does other programs' data it can't write (`not_saved` reports + /// the entities). So does a DXF whose classes are unknown (`classes` is `None`, a binary + /// DXF): its proxies' numbers can't be mapped, and kept as they are they would name the + /// wrong classes and the ODA would refuse the file. Returns the proxies left out, by DXF type, for + /// what the DWG writer left out ([`super::Unwritten`]). + fn proxy_classes(doc: &mut acadrust::CadDocument, classes: Option<&[String]>) -> super::Unwritten { + let classes = match classes { + Some(c) if doc.version >= acadrust::DxfVersion::AC1027 => c, + _ => { + let entities: Vec = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => { + Some(x.common.handle) + } + _ => None, + }) + .collect(); + let objects: Vec = + doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect(); + for h in &entities { + doc.remove_entity(*h); + } + for h in &objects { + doc.objects.remove(h); + } + return left_out(objects.len(), entities.len()); + } + }; + // Name matching cannot choose between different source classes with the same name. + // Count those proxies as unwritten rather than silently changing their class identity. + let mut unique = std::collections::HashMap::new(); + for name in classes { + unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true); + } + let number = |id: i32| -> Option { + let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?; + if unique.get(&name.to_uppercase()) != Some(&true) { + return None; + } + doc.classes.get_by_name(name).map(|c| i32::from(c.class_number)) + }; + let objects: Vec<(acadrust::Handle, Option)> = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + acadrust::objects::ObjectType::ProxyObject(p) => Some((*h, number(p.class_id))), + _ => None, + }) + .collect(); + let entities: Vec<(acadrust::Handle, Option)> = doc + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Extended(x) => match &x.data { + acadrust::entities::ExtendedEntityData::Proxy(p) => Some((x.common.handle, number(p.class_id))), + _ => None, + }, + _ => None, + }) + .collect(); + let unmapped = |list: &[(acadrust::Handle, Option)]| list.iter().filter(|(_, n)| n.is_none()).map(|(h, _)| *h).collect::>(); + let (gone_objects, gone_entities) = (unmapped(&objects), unmapped(&entities)); + for h in &gone_objects { + doc.objects.remove(h); + } + for h in &gone_entities { + doc.remove_entity(*h); + } + let objects = objects.into_iter().filter_map(|(h, n)| Some((h, n?))); + let entities = entities.into_iter().filter_map(|(h, n)| Some((h, n?))); + for (h, n) in objects { + if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) { + p.class_id = n; + } + } + let entities: std::collections::HashMap = entities.into_iter().collect(); + for e in doc.entities_mut() { + if let acadrust::entities::EntityType::Extended(x) = e { + let Some(n) = entities.get(&x.common.handle).copied() else { continue }; + if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data { + p.class_id = n; + } + } + } + left_out(gone_objects.len(), gone_entities.len()) + } + + /// Proxy objects and entities left out of the DWG, by their DXF types. + fn left_out(objects: usize, entities: usize) -> super::Unwritten { + [("ACAD_PROXY_OBJECT", objects), ("ACAD_PROXY_ENTITY", entities)] + .into_iter() + .filter(|(_, n)| *n > 0) + .map(|(t, n)| (t.to_string(), n)) + .collect() + } + + /// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG + /// stores the handle of the layer, and the one read with the DXF needn't be the layer's in + /// the document, so the drawing reopened with layer "0" current (#2252). + fn current_layer(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_layer_name.clone(); + // The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä"). + if let Some(layer) = doc.layers.get(&name) { + doc.header.current_layer_handle = layer.handle; + } + } + + /// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]). + fn convert(dxf: &[u8], version: Option, kept: Option) -> Result<(Vec, super::Unwritten), String> { + // Reject a pathologically large DXF before handing it to the third-party decoder, which + // allocates tables and section buffers from the input's declared sizes and cannot be + // interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder + // runs inside `catch_unwind` so a decode failure is reported rather than crashing. + if dxf.len() > Limits::DEFAULT.max_dxf_bytes { + return Err(format!( + "DXF: the input is {}, larger than the {} accepted for DWG conversion", + mb(dxf.len() as u64), + mb(Limits::DEFAULT.max_dxf_bytes as u64) + )); + } + // Decoding a code page to UTF-8 can grow the text, so the limit applies again. + let data = match kept.and_then(|k| declare_version(dxf, k)) { + Some(d) if d.len() > Limits::DEFAULT.max_dxf_bytes => { + return Err(format!( + "DXF: the input is {} as UTF-8, larger than the {} accepted for DWG conversion", + mb(d.len() as u64), + mb(Limits::DEFAULT.max_dxf_bytes as u64) + )); + } + Some(d) => d, + None => dxf.to_vec(), + }; + let (classes, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data)))) + .map_err(|_| "the DXF proxy class conversion failed".to_string())?; + let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) + .map_err(|_| "the DXF→DWG conversion failed".to_string())? + .map_err(|e| format!("DXF: {e}"))?; + match version { + Some(v) => doc.version = v, + // Without a version asked for, write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG + // has no true colours, transparency, table styles or gradients. The DXF stays R2000; + // only the DWG is newer. + None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION, + None => {} + } + // The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash. + let unwritten = guarded(AssertUnwindSafe(|| { + super::fail_in_test("convert"); + // From 2013 the DWG takes the source classes as they are, duplicates and metadata + // included; otherwise (before 2013, a binary DXF, classes that can't be written back) + // proxies are matched to classes by name or left out. + let restored = match source { + Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source), + _ => None, + }; + let proxies = match restored { + Some((objects, entities)) => left_out(objects, entities), + None => proxy_classes(&mut doc, classes.as_deref()), + }; + other_layout_owners(&mut doc); + table_style_rows(&mut doc, true); + dimension_flags(&mut doc); + let mut unwritten = drop_unwritable(&mut doc); + for (kind, n) in proxies { + let total = unwritten.entry(kind).or_insert(0); + *total = total.saturating_add(n); + } + current_dimstyle(&mut doc); + add_variable_dictionary(&mut doc); + current_text_style(&mut doc); + current_layer(&mut doc); + unwritten + })) + .map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?; + let dwg = 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}"))?; + Ok((dwg, unwritten)) + } + + /// 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 + /// second layout landed on the first one, and the second stayed empty (#1057). + fn other_layout_owners(doc: &mut acadrust::CadDocument) { + let others: std::collections::HashSet = doc + .block_records + .iter() + .filter(|br| { + let n = br.name.to_ascii_uppercase(); + n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit()) + }) + .map(|br| br.handle) + .collect(); + if others.is_empty() { + return; + } + for e in doc.entities_mut() { + let c = e.common_mut(); + if others.contains(&c.owner_handle) { + c.entity_mode = Some(0); + } + } + } + + #[cfg(test)] + mod post_tests { + use super::*; + use acadrust::entities::{CellRange, EntityType, Table}; + + fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument { + let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols); + t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect(); + let mut doc = acadrust::CadDocument::new(); + assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok()); + doc + } + + /// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934). + #[test] + fn merge_work_is_bounded_by_the_cell_budget() { + let mut doc = doc_with_table(10, 10, 50); + assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped"); + let mut doc = doc_with_table(10, 10, 1); + assert_eq!(table_merges_within(&mut doc, usize::MAX), 100); + } + + /// A height marked only in the style's override flags (as newer files store it) is kept, + /// and the same height with no flag at all is cleared (review on #2588). Tested on the + /// read document because acadrust's DWG writer doesn't write the override flags. + #[test] + fn a_style_override_flag_keeps_the_cell_height() { + use acadrust::entities::table::{CellStyle, CellStylePropertyFlags}; + let height = |override_flags: i32| { + let mut doc = doc_with_table(1, 1, 0); + for e in doc.entities_mut() { + if let EntityType::Table(t) = e { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut s = CellStyle::new(); + s.text_height = 0.5; + s.override_flags = override_flags; + cell.style = Some(s); + } + } + } + table_cell_heights(&mut doc); + doc.entities().find_map(|e| match e { + EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height), + _ => None, + }) + }; + assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5)); + assert_eq!(height(0), Some(0.0)); + } + + /// A panic in the post-read fix-ups is an error, not a crash (#1934). + #[test] + fn a_panic_in_post_processing_is_an_error() { + #[allow(clippy::panic)] + let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") })); + assert!(r.is_err()); + 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 this bridge can't preserve 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))] +pub use native::{ + dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_kept_as, dxf_to_dwg_kept_noted, dxf_to_dwg_noted, +}; + +/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer +/// can't write. +pub type Unwritten = std::collections::BTreeMap; + +// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The +// stage then panics on entry, so a test through the public API shows whether it is guarded. +#[cfg(all(test, not(no_dwg_bridge)))] +thread_local! { + static PANIC_IN: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise. +#[cfg(not(no_dwg_bridge))] +#[allow(clippy::panic)] +fn fail_in_test(_stage: &str) { + #[cfg(test)] + if PANIC_IN.with(|p| p.get()) == Some(_stage) { + panic!("test-forced panic in {_stage}"); + } +} + +/// The error for a DWG version this writer can't create. +#[cfg_attr(no_dwg_bridge, allow(dead_code))] +fn unsupported(version: &str) -> String { + let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect(); + format!("DWG version `{version}` can't be written (this bridge writes {})", list.join(", ")) +} + +/// Which build has no DWG bridge, for the stubs' errors. +#[cfg(no_dwg_bridge)] +const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" }; + +/// There's no worker without a bridge. +#[cfg(no_dwg_bridge)] +pub const WORKER_ARG: &str = "--dwg-worker"; +#[cfg(no_dwg_bridge)] +pub fn serve_if_asked() -> Option { + None +} +#[cfg(no_dwg_bridge)] +pub fn use_own_worker() {} +#[cfg(no_dwg_bridge)] +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + dwg_to_dxf_noted(bytes, limits) +} +#[cfg(no_dwg_bridge)] +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_as(dxf, version) +} +#[cfg(no_dwg_bridge)] +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, Unwritten), String> { + dxf_to_dwg_noted(dxf, version) +} +#[cfg(no_dwg_bridge)] +pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, Unwritten), String> { + match kept { + Some(kept) => dxf_to_dwg_kept_noted(dxf, version, kept), + None => dxf_to_dwg_noted(dxf, version), + } +} + +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec, Vec), String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result, String> { + Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg(_dxf: &[u8]) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec, Unwritten), String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_kept_as(_dxf: &[u8], _version: &str, _kept: &str) -> Result, String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} +#[cfg(no_dwg_bridge)] +pub fn dxf_to_dwg_kept_noted(_dxf: &[u8], _version: &str, _kept: &str) -> Result<(Vec, Unwritten), String> { + Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The DXF of an empty drawing, enough for the bridge to make a small DWG. + #[cfg(not(no_dwg_bridge))] + const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n"; + + /// Makes `stage` panic on the current thread until dropped. + #[cfg(not(no_dwg_bridge))] + struct PanicIn; + + #[cfg(not(no_dwg_bridge))] + impl PanicIn { + fn at(stage: &'static str) -> Self { + PANIC_IN.with(|p| p.set(Some(stage))); + Self + } + } + + #[cfg(not(no_dwg_bridge))] + impl Drop for PanicIn { + fn drop(&mut self) { + PANIC_IN.with(|p| p.set(None)); + } + } + + /// The public read reports a panic in the post-read fix-ups as an error: this fails if the + /// `guarded` call around `post_process` is taken out (#2402). + #[cfg(not(no_dwg_bridge))] + #[test] + fn reading_a_dwg_survives_a_panic_in_post_processing() { + let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap(); + assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic"); + let _panic = PanicIn::at("post_process"); + let err = dwg_to_dxf(&dwg).unwrap_err(); + assert!(err.contains("the DWG post-processing failed on this file"), "{err}"); + } + + /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). + #[cfg(not(no_dwg_bridge))] + #[test] + fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { + let _panic = PanicIn::at("convert"); + let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err(); + assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}"); + } + + #[test] + fn detects_magic() { + assert!(is_dwg(b"AC1032\0\0\0\0")); + assert!(!is_dwg(b" 0\r\nSECTION")); + assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018")); + } + + /// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not + /// Standard: the DXF names the style, and the handle read with it was Standard's (#1650). + #[cfg(not(no_dwg_bridge))] + #[test] + fn current_text_style_reaches_the_dwg() { + let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n"; + let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n"; + let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n"); + let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n"); + let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle); + assert!(romans.is_some(), "ROMANS is missing from the DWG"); + assert_eq!(Some(doc.header.current_text_style_handle), romans); + } + + #[test] + fn garbage_is_an_error_not_a_crash() { + assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err()); + assert!(dwg_to_dxf(b"").is_err()); + } + + #[cfg(not(no_dwg_bridge))] + #[test] + fn dxf_to_dwg_rejects_garbage_without_crashing() { + // The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures + // rather than crashing (and rejects oversized input before decoding). + assert!(dxf_to_dwg(b"not a dxf file").is_err()); + assert!(dxf_to_dwg(b"").is_err()); + } + + /// A DXF with one DIMENSION per (text, group 70, subclass, points) row. + #[cfg(not(no_dwg_bridge))] + type DimensionSpec<'a> = (&'a str, i64, &'a str, &'a [(i32, [f64; 2])]); + + #[cfg(not(no_dwg_bridge))] + fn dims_dxf(dims: &[DimensionSpec<'_>]) -> String { + let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n"); + for (text, flags, sub, pts) in dims { + s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n"; + s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n"); + for (code, [x, y]) in pts.iter() { + s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20); + } + } + s + " 0\nENDSEC\n 0\nEOF\n" + } + + /// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set: + /// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common + /// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way + /// as the writer can't hide a wrong byte. + #[cfg(not(no_dwg_bridge))] + #[test] + fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() { + let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])]; + let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])]; + let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])]; + let kinds = [ + (0, "AcDbAlignedDimension", linear), + (1, "AcDbAlignedDimension", linear), + (4, "AcDbRadialDimension", radius), + (2, "AcDb2LineAngularDimension", angular), + ]; + let mut dims = Vec::new(); + for (ty, sub, pts) in kinds { + dims.push(("HOME", ty | 32, sub, pts)); + dims.push(("MOVED", ty | 32 | 128, sub, pts)); + } + let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap(); + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let mut seen = Vec::new(); + for e in doc.entities() { + let acadrust::entities::EntityType::Dimension(d) = e else { continue }; + let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte); + seen.push((text.to_string(), byte & 0x03)); + assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type); + let default_pos = text == "HOME"; + assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type); + } + for text in ["HOME", "MOVED"] { + assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}"); + } + + // And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts. + let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap(); + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let mut flags = Vec::new(); + for rec in lines.chunks(2).collect::>().split(|p| p.first() == Some(&"0")) { + let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied()); + if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) { + flags.push((text, v.parse::().unwrap_or(-1) & 128)); + } + } + assert_eq!(flags.len(), 8, "{flags:?}"); + assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}"); + } + + /// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369). + #[cfg(not(no_dwg_bridge))] + fn dxf_handles(dxf: &str) -> std::collections::HashSet { + let lines: Vec<&str> = dxf.lines().collect(); + lines + .chunks(2) + .filter_map(|p| match p { + [c, v] if matches!(c.trim().parse::(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(), + _ => None, + }) + .collect() + } + + /// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN + /// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a + /// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and + /// their dictionary entries with them; the definition and every component it reaches — + /// through compound and point components, around a cycle — are kept. + #[cfg(not(no_dwg_bridge))] + #[test] + fn orphaned_dgn_line_style_components_are_left_out() { + use acadrust::objects::*; + let mut doc = acadrust::CadDocument::new(); + let container = doc.allocate_handle(); + let nod = doc.header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") }; + root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container)); + let pattern = || DgnLsStrokePattern { + has_iteration_limit: false, + is_single_segment: false, + iteration_limit: 0, + auto_phase: 0.0, + phase: 0.0, + phase_mode: DgnLsPhaseMode::Distance, + strokes: vec![DgnLsStroke { + is_dash: true, + bypass_corner: false, + can_be_scaled: true, + invert_at_origin: false, + invert_at_end: false, + length: 2.5, + start_width: 0.0, + end_width: 0.0, + width_mode: 0, + cap_mode: 0, + }], + }; + let kind = |c: &DgnLsComponentData| match c { + DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol, + DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound, + DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke, + DgnLsComponentData::Point(_) => DgnLsComponentType::Point, + DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal, + }; + let compound = |of: &[acadrust::Handle]| { + DgnLsComponentData::Compound(DgnLsCompoundComponent { + entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(), + }) + }; + let mut entries = Vec::new(); + let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| { + entries.push((format!("{:X}", h.value()), h)); + let reactors = vec![container]; + doc.objects + .insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data })); + }; + let component = |c: DgnLsComponentData| DgnLineStyleData::Component { + kind: kind(&c), + description: "dash".into(), + version: 1, + component_uid: [7; 16], + scale: 1.0, + property_flags: 0, + component: c, + properties: vec![], + }; + let h: Vec = (0..9).map(|_| doc.allocate_handle()).collect(); + let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") }; + put( + &mut doc, + def, + DgnLineStyleData::Definition { + description: "{ dash }".into(), + version: 1, + style_number: 1, + component_uid: [1; 16], + is_continuous: false, + unit_definition: 1.0, + unit_scale: 1.0, + units_type: 0, + is_element: false, + is_physical: false, + is_scale_independent: false, + is_snappable: false, + root_component: root, + properties: vec![], + }, + ); + // Used: def → root → {stroke, point → shared, back → root (a cycle)}. + put(&mut doc, root, component(compound(&[stroke, point, back]))); + put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern()))); + put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] }))); + put(&mut doc, back, component(compound(&[root]))); + put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern()))); + // Orphans: an internal component, and two compounds that refer to each other and to a + // used component (which stays). + let pattern_internal = + DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 }; + put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal))); + put(&mut doc, orphan, component(compound(&[orphan_cycle, shared]))); + put(&mut doc, orphan_cycle, component(compound(&[orphan]))); + let mut dict = Dictionary::new(); + dict.handle = container; + dict.owner = nod; + dict.entries = entries; + doc.objects.insert(container, ObjectType::Dictionary(dict)); + let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + + let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap(); + let dxf = String::from_utf8(dxf).unwrap(); + let handles = dxf_handles(&dxf); + for kept in [def, root, stroke, point, back, shared] { + assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value()); + } + for gone in [internal, orphan, orphan_cycle] { + assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value()); + } + let lines: Vec<&str> = dxf.lines().collect(); + let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect(); + assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}"); + assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}"); + assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]); + } + + /// An R2000 DXF header (`\r\n` lines, as CADCraft writes them) with `extra` after `$ACADVER`. + #[cfg(not(no_dwg_bridge))] + fn r2000(extra: &[u8]) -> Vec { + let mut dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 9\r\n$DWGCODEPAGE\r\n 3\r\nANSI_1252\r\n".to_vec(); + dxf.extend_from_slice(b" 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\n"); + dxf.extend_from_slice(extra); + dxf.extend_from_slice(b"\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"); + dxf + } + + /// The kept version replaces only the `$ACADVER` value; everything else stays byte for byte. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_changes_only_acadver() { + let dxf = r2000(b"A\\U+00E7\\U+00E3o"); + let out = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), String::from_utf8(dxf).unwrap().replacen("AC1015", "AC1032", 1)); + } + + /// A kept version not newer than the declared one, a binary DXF and a DXF without `$ACADVER` + /// are read as they are. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_leaves_what_it_need_not_change() { + let dxf = r2000(b"x"); + assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1015), None); + assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1009), None); + assert_eq!(native::declare_version(b"AutoCAD Binary DXF\r\n\x1a\0", acadrust::DxfVersion::AC1032), None); + let no_version = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::declare_version(no_version, acadrust::DxfVersion::AC1032), None); + // A `$ACADVER` outside the HEADER section is not the file's version. + let late = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n"; + assert_eq!(native::declare_version(late, acadrust::DxfVersion::AC1032), None); + } + + /// The header is read as group code and value pairs up to the first `0`/`ENDSEC`: a `$ACADVER` + /// name whose value would come after it, or a file that never ends its HEADER, is no version + /// to replace, and `\r\n` lines keep the value's offset right. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_reads_the_header_in_pairs_up_to_endsec() { + let cut = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 0\r\nENDSEC\r\n 1\r\nAC1015\r\n"; + assert_eq!(native::declare_version(cut, acadrust::DxfVersion::AC1032), None); + let endless = vec![b'\n'; 1 << 20]; + assert_eq!(native::declare_version(&endless, acadrust::DxfVersion::AC1032), None); + let crlf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1015\r\n 0\r\nENDSEC\r\n"; + let out = native::declare_version(crlf, acadrust::DxfVersion::AC1032).unwrap(); + assert_eq!(out, b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1032\r\n 0\r\nENDSEC\r\n"); + } + + /// Declared as 2007 or later, a DXF is UTF-8: code page text is decoded first. Declared as an + /// older version, the code page bytes stay as they are. + #[cfg(not(no_dwg_bridge))] + #[test] + fn declare_version_decodes_a_code_page_for_utf8_versions() { + let dxf = r2000(b"A\xe7\xe3o"); + let utf8 = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap(); + let text = String::from_utf8(utf8).unwrap(); + assert!(text.contains("\r\nAção\r\n") && text.contains("$ACADVER\r\n 1\r\nAC1032\r\n"), "{text}"); + let legacy = native::declare_version(&dxf, acadrust::DxfVersion::AC1018).unwrap(); + assert!(legacy.windows(4).any(|w| w == b"A\xe7\xe3o"), "the code page bytes stay"); + assert!(legacy.windows(6).any(|w| w == b"AC1018")); + // A code page it doesn't know is read as Windows-1252, as acadrust reads an older DXF. + let at = dxf.windows(9).position(|w| w == b"ANSI_1252").unwrap(); + let unknown = [&dxf[..at], b"NO_SUCH_CP".as_slice(), &dxf[at + 9..]].concat(); + let text = String::from_utf8(native::declare_version(&unknown, acadrust::DxfVersion::AC1032).unwrap()).unwrap(); + assert!(text.contains("\r\nAção\r\n"), "{text}"); + // Without a `$DWGCODEPAGE` there is nothing to decode it by: it is read as it is. + let none = [&dxf[..at - 21], &dxf[at + 11..]].concat(); + assert!(!none.windows(12).any(|w| w == b"$DWGCODEPAGE"), "{}", String::from_utf8_lossy(&none)); + assert_eq!(native::declare_version(&none, acadrust::DxfVersion::AC1032), None); + } + + /// An unknown kept version converts as [`dxf_to_dwg_as`] does. + #[cfg(not(no_dwg_bridge))] + #[test] + fn unknown_kept_version_converts_as_before() { + let dxf = r2000(b"x"); + assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032")); + assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error"); + } + + /// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES. + #[cfg(not(no_dwg_bridge))] + #[test] + fn dxf_class_names_in_classes_order() { + let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::dxf_class_names(dxf).unwrap(), ["WIDGETA", "WIDGETB"]); + // A CLASS record without a name keeps its place, so the classes after it keep theirs + // (#2837 review). + let unnamed = b" 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 2\r\nAcDbNoName\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert_eq!(native::dxf_class_names(unnamed).unwrap(), ["", "WIDGETB"]); + assert_eq!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n"), Some(Vec::new())); + assert_eq!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0"), None); + } + + /// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three + /// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD + /// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales + /// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also + /// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action + /// from DXF is written whole, with the byte AutoCAD writes (0). + #[cfg(not(no_dwg_bridge))] + #[test] + fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() { + use acadrust::objects::*; + let scale_action = |doc: &acadrust::CadDocument| { + doc.objects.values().find_map(|o| match o { + ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()), + _ => None, + }) + }; + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let conn = |code, name: &str| BlockConnection { code, name: name.into() }; + let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction"); + action.handle = h; + action.owner = doc.header.named_objects_dict_handle; + let mut a = BlockBasePointAction::default(); + a.action.action.element.name = "Scale".into(); + a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")]; + a.action.dependent = true; + a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")]; + a.dwg_tail_byte = 7; + action.data = DynamicBlockData::ScaleAction(a.clone()); + doc.objects.insert(h, ObjectType::DynamicBlock(action)); + + let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written"); + + let mut dxf = Vec::new(); + acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap(); + for version in ["AC1018", "AC1032"] { + let dwg = dxf_to_dwg_as(&dxf, version).unwrap(); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written")); + assert_eq!(read.connections, a.connections, "{version}"); + assert_eq!(read.action.connections, a.action.connections, "{version}"); + assert_eq!(read.dwg_tail_byte, 0, "{version}"); + } + } + + /// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF + /// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG + /// keeps whole reports nothing. + #[cfg(not(no_dwg_bridge))] + #[test] + fn saving_lists_the_types_the_dwg_writer_leaves_out() { + let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n"; + let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n"; + let dxf = |body: &str| { + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n") + }; + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap(); + assert!(is_dwg(&dwg), "{version}"); + assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::(), "{version}"); + } + let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap(); + assert!(unwritten.is_empty(), "{unwritten:?}"); + } + + /// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to + /// the DWG with its data: it isn't in the list of what the writer left out. + #[cfg(not(no_dwg_bridge))] + #[test] + fn ole_frames_are_written_with_their_data() { + let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n"; + let dxf = + format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n"); + for version in ["AC1015", "AC1018", "AC1032"] { + let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap(); + assert!(unwritten.is_empty(), "{version} {unwritten:?}"); + let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap(); + let frames: Vec> = back + .entities() + .filter_map(|e| match e { + acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()), + _ => None, + }) + .collect(); + assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}"); + } + } +} diff --git a/crates/dwg/src/proxy_transport.rs b/crates/dwg/src/proxy_transport.rs new file mode 100644 index 000000000..db0b984ec --- /dev/null +++ b/crates/dwg/src/proxy_transport.rs @@ -0,0 +1,632 @@ +//! Another application's entities and their classes through the DWG bridge: on reading a DWG, +//! registered entities acadrust has no model for become proxy entities of their class; on writing +//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class. + +use std::collections::{HashMap, HashSet}; + +use acadrust::CadDocument; +use acadrust::classes::{DxfClass, ProxyFlags}; +use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData}; +use acadrust::objects::{ObjectType, ProxyPayload}; + +fn class_number(index: usize) -> Result { + index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into()) +} + +/// After a DWG read: each registered entity of another application's class that acadrust keeps +/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class +/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object +/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's +/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is +/// missing or not unique) is left out and named in the notes; one with decoded properties stays as +/// read, also named, as its opaque data has no DXF form beside them. +pub(super) fn prepare(doc: &mut CadDocument) -> Vec { + let classes: Vec<_> = doc.classes.iter().cloned().collect(); + let mut numbers = HashMap::new(); + let mut identities = HashMap::new(); + for (index, class) in classes.iter().enumerate() { + let number = class_number(index).ok(); + numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from)); + let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity); + identities.entry(identity).and_modify(|v| *v = None).or_insert(number); + } + let identity = |dxf: &str, cpp: &str, entity: bool| { + identities + .get(&(dxf, cpp, entity)) + .copied() + .flatten() + .map(i32::from) + .ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})")) + }; + let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() }; + let maintenance = i32::from(doc.maintenance_version); + let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}")); + // Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity. + let handles: Vec<_> = doc + .entities() + .filter_map(|e| match e { + EntityType::Extended(e) => match &e.data { + ExtendedEntityData::Proxy(_) => Some(e.common.handle), + ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle), + _ => None, + }, + _ => None, + }) + .collect(); + let mut notes = Vec::new(); + let mut entity_count = 0usize; + let mut object_count = 0usize; + let mut semantic = 0usize; + for handle in handles { + // Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties. + let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result>, String> { + let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) }; + let data = match &entity.data { + ExtendedEntityData::Proxy(data) => { + let mut proxy = data.clone(); + proxy.class_id = remap(proxy.class_id)?; + ExtendedEntityData::Proxy(proxy) + } + ExtendedEntityData::RegisteredClass(data) => { + if !data.properties.is_empty() { + return Ok(Some(None)); + } + let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?; + let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default(); + ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id, + dxf_subclass: String::new(), + version: (maintenance << 16) | dwg_version, + dwg_version, + maintenance_version: maintenance, + from_dxf: false, + graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()), + payload: data.payload.clone(), + text_payload, + object_ids: data.object_ids.clone(), + }) + } + _ => return Ok(None), + }; + Ok(Some(Some(data))) + })) + .unwrap_or_else(|_| Err("application record conversion failed".into())); + match converted { + Ok(Some(Some(data))) => { + if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) { + entity.data = data; + } + } + Ok(Some(None)) => semantic = semantic.saturating_add(1), + Ok(None) => {} + Err(why) => { + doc.remove_entity(handle); + entity_count = entity_count.saturating_add(1); + skipped_note(&mut notes, "entity", handle, &why); + } + } + } + doc.objects.retain(|handle, object| { + if let ObjectType::ProxyObject(proxy) = object { + match remap(proxy.class_id) { + Ok(id) => proxy.class_id = id, + Err(why) => { + object_count = object_count.saturating_add(1); + skipped_note(&mut notes, "object", *handle, &why); + return false; + } + } + } + true + }); + if entity_count > 0 || object_count > 0 { + notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})")); + } + if semantic > 0 { + notes.push(format!( + "DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it" + )); + } + notes +} + +fn skipped_note(notes: &mut Vec, kind: &str, handle: acadrust::types::Handle, why: &str) { + if notes.len() < 6 { + let why: String = why.chars().take(256).collect(); + notes.push(format!("DWG: proxy {kind} {handle} left out: {why}")); + } +} + +/// The decoder's registered-class representation omits the separate R2007+ string stream. +fn registered_text(raw: &acadrust::entities::RawRecord) -> Result { + use acadrust::io::dwg::DwgVersion; + use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader; + use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter; + use acadrust::types::DxfVersion; + + if raw.version < DxfVersion::AC1021 { + return Ok(ProxyPayload::default()); + } + let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?; + let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?; + let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?; + if handle_bits > total_bits { + return Err("DWG proxy text: handle stream exceeds record".into()); + } + // Frame the verbatim record for the dependency's checked object reader. + 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, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?; + let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?; + reader.read_common_entity_data(&mut streams, kind); + if kind == 498 { + // An existing registered-class envelope is a proxy and carries its class name first. + streams.read_variable_text(); + } else if kind < 500 { + return Err("DWG proxy text: registered entity has no application class".into()); + } + streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?; + let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?; + if bits > total_bits { + return Err("DWG proxy text: string stream exceeds record".into()); + } + let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?; + let mut bytes = vec![0; bits.div_ceil(8)]; + for (index, byte) in bytes.iter_mut().enumerate() { + for bit in 0..8.min(bits.saturating_sub(index * 8)) { + if streams.read_text_bit() { + *byte |= 0x80 >> bit; + } + } + } + streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?; + Ok(ProxyPayload::from_bits(&bytes, count)) +} + +/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed +/// pair yields `None`, so both class scans fall back rather than guessing its identity. +pub(super) fn text_pairs(dxf: &[u8]) -> Option> + '_> { + use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page}; + + if dxf.starts_with(b"AutoCAD Binary DXF") { + return None; + } + let version = + super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string); + let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021); + let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) { + super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page) + } else { + None + }; + let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line)); + Some(std::iter::from_fn(move || { + let code = lines.next()?; + let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) }; + let pair = || { + let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?; + let text = if let Some(encoding) = encoding { + encoding.decode(value).0.into_owned() + } else if unicode { + String::from_utf8_lossy(value).into_owned() + } else { + // The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared. + std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string) + }; + let text = decode_legacy_escapes(&text); + // DXF's escaped control characters are decoded after CIF/MIF, as in the bridge. + let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^"); + Some((code, text)) + }; + Some(pair()) + })) +} + +/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF +/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (the bridge's +/// generated DXF is ASCII), and for one with a record that can't be written back as it is: no names, +/// flags or instance count out of range, more classes than the numbers allow. Then +/// `proxy_classes` matches the classes by name instead. +pub(super) fn source_classes(dxf: &[u8]) -> Option> { + let pairs = text_pairs(dxf)?; + let mut records: Vec> = Vec::new(); + let (mut section, mut in_classes) = (false, false); + for pair in pairs { + let (code, value) = pair?; + match (code, value.as_str()) { + (0, "ENDSEC") if in_classes => break, + (0, "SECTION") => section = true, + (2, name) if section => { + in_classes = name == "CLASSES"; + section = false; + } + (0, kind) if in_classes => { + if kind != "CLASS" { + return None; + } + records.push(Vec::new()); + } + (code, value) if in_classes => records.last_mut()?.push((code, value.to_string())), + _ => section = false, + } + } + records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect() +} + +/// One CLASS record's groups as the class `number`, or `None` when it can't be written back. +fn source_class(groups: &[(i32, String)], number: i16) -> Option { + let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default(); + let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::().ok()); + let mut class = DxfClass::new(text(1), text(2)); + if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() { + return None; + } + class.application_name = text(3); + class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?); + class.instance_count = i32::try_from(integer(91)?).ok()?; + class.was_zombie = integer(280)? != 0; + class.is_an_entity = integer(281)? != 0; + class.item_class_id = if class.is_an_entity { 498 } else { 499 }; + class.class_number = number; + Some(class) +} + +/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in +/// their places, with their metadata and duplicate names, so each proxy's DXF class number names +/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number +/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and +/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing +/// changed, when the classes would be more than the class numbers allow. +pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec) -> Option<(usize, usize)> { + let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len()); + let mut any = false; + let mut entities = Vec::new(); + for entity in doc.entities() { + if let EntityType::Extended(e) = entity + && let ExtendedEntityData::Proxy(p) = &e.data + { + any = true; + if !fits(p.class_id) { + entities.push(e.common.handle); + } + } + } + let mut objects = Vec::new(); + for (handle, object) in &doc.objects { + if let ObjectType::ProxyObject(p) = object { + any = true; + if !fits(p.class_id) { + objects.push(*handle); + } + } + } + if !any { + return Some((0, 0)); + } + let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect(); + let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect(); + if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) { + class_number(last).ok()?; + } + for handle in &entities { + doc.remove_entity(*handle); + } + for handle in &objects { + doc.objects.remove(handle); + } + doc.classes.clear(); + for (index, mut class) in classes.into_iter().chain(defaults).enumerate() { + class.class_number = class_number(index).ok()?; + doc.classes.push_preserving(class); + } + Some((objects.len(), entities.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData}; + use acadrust::types::DxfVersion; + + fn proxy_doc() -> CadDocument { + let mut doc = CadDocument::new(); + doc.version = DxfVersion::AC1032; + doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id: 501, + dxf_subclass: String::new(), + version: 33, + dwg_version: 33, + maintenance_version: 0, + from_dxf: false, + graphics: ProxyPayload::default(), + payload: ProxyPayload::from_bits(&[0x80], 1), + text_payload: ProxyPayload::default(), + object_ids: vec![], + }), + }))) + .unwrap(); + doc + } + + /// An ASCII DXF of `groups`, `(code, value)` per pair of lines. + fn dxf(groups: &[(i32, &str)]) -> Vec { + groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::().into_bytes() + } + + #[test] + fn source_classes_keep_duplicates_and_metadata_before_defaults() { + let bytes = dxf(&[ + (0, "SECTION"), + (2, "HEADER"), + (9, "$DIMSTYLE"), + (2, "CLASSES"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "CLASSES"), + (0, "CLASS"), + (1, "Объект"), + (2, "First"), + (3, "Первый"), + (90, "1025"), + (91, "7"), + (280, "1"), + (281, "1"), + (0, "CLASS"), + (1, "Объект"), + (2, "Second"), + (3, "Второй"), + (90, "128"), + (281, "1"), + (0, "ENDSEC"), + (0, "EOF"), + ]); + let classes = source_classes(&bytes).unwrap(); + assert_eq!(classes.len(), 2); + assert_eq!(classes[0].application_name, "Первый"); + assert_eq!(classes[0].proxy_flags.0, 1025); + assert_eq!(classes[0].instance_count, 7); + assert!(classes[0].was_zombie); + assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501)); + assert_eq!(classes[1].cpp_class_name, "Second"); + let mut doc = proxy_doc(); + assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0))); + assert_eq!(doc.classes.iter().take(2).cloned().collect::>(), classes); + // A binary DXF, and a CLASS record that can't be written back as it is, give no classes. + assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none()); + for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] { + let mut class = vec![(1, "A"), (2, "A")]; + class.retain(|(c, _)| *c != code); + class.push((code, value)); + let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")]; + groups.extend(class); + groups.extend([(0, "ENDSEC"), (0, "EOF")]); + assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}"); + } + } + + #[test] + fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() { + let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n"; + let text = format!("0\nSECTION\n2\nCLASSES\n{body}"); + let classes = source_classes(text.as_bytes()).unwrap(); + assert_eq!(classes[0].dxf_name, "Объект"); + assert_eq!(classes[0].application_name, "Café🙂\\U+0041"); + + let prefix = + b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n"; + let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat(); + assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример"); + // R2007+ text is UTF-8 even with a legacy code-page declaration. + let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"; + assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример"); + } + + #[test] + fn proxies_of_missing_classes_are_left_out() { + for id in [-1, 499, 501, i32::MAX] { + let mut doc = proxy_doc(); + if let Some(EntityType::Extended(e)) = doc.entities_mut().next() + && let ExtendedEntityData::Proxy(p) = &mut e.data + { + p.class_id = id; + } + let mut class = DxfClass::new_entity("ENTITY", "Entity"); + class.class_number = 500; + assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}"); + assert_eq!(doc.entities().count(), 0); + } + // Class 501 is there: the proxy keeps it, in its place. + let mut doc = proxy_doc(); + assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0))); + assert_eq!(doc.entities().count(), 1); + assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501))); + // Without proxies the classes stay the decoder's. + let mut doc = CadDocument::new(); + let before: Vec<_> = doc.classes.iter().cloned().collect(); + assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0))); + assert_eq!(doc.classes.iter().cloned().collect::>(), before); + } + + #[test] + fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() { + use acadrust::entities::{Line, RawRecord}; + use acadrust::objects::ProxyObject; + use acadrust::types::{Handle, Vector3}; + for count in [1, 10] { + let mut doc = proxy_doc(); + if let Some(EntityType::Extended(e)) = doc.entities_mut().next() + && let ExtendedEntityData::Proxy(p) = &mut e.data + { + p.class_id = i32::MAX; + } + let template = doc.entities().next().unwrap().clone(); + for _ in 1..count { + let mut entity = template.clone(); + entity.common_mut().handle = Handle::NULL; + doc.add_entity(entity).unwrap(); + } + let object = Handle::new(u64::MAX - 1); + assert!(!doc.objects.contains_key(&object)); + doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() })); + let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap(); + let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 }; + doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone())); + let notes = prepare(&mut doc); + assert_eq!(notes.len(), (count + 1).min(6) + 1); + assert!(notes.iter().all(|n| n.starts_with("DWG:"))); + assert!(notes[0].contains(&format!("entities: {count}, objects: 1"))); + assert_eq!(doc.entities().count(), 1); + assert!(!doc.objects.contains_key(&object)); + let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") }; + assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0)); + assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0)); + assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw)); + } + } + #[test] + fn opaque_registered_class_ambiguity_is_not_guessed() { + let mut doc = CadDocument::new(); + doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom")); + doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom")); + doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "CUSTOM".into(), + cpp_class_name: "Custom".into(), + properties: vec![], + payload: ProxyPayload::from_bits(&[0x80], 1), + object_ids: vec![], + }), + }))) + .unwrap(); + assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous"))); + assert_eq!(doc.entities().count(), 0); + assert!(class_number(usize::MAX).is_err()); + } + #[test] + fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() { + use acadrust::entities::RawRecord; + use acadrust::objects::{SemanticProperty, SemanticPropertyValue}; + for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] { + let mut doc = CadDocument::new(); + let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 }; + let handle = doc + .add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "SEMANTIC".into(), + cpp_class_name: "Semantic".into(), + properties: vec![SemanticProperty { + subclass: "Semantic".into(), + code: 1, + value: SemanticPropertyValue::Text("value".into()), + }], + payload: payload.clone(), + object_ids: vec![], + }), + }))) + .unwrap(); + doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone())); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + if payload.bit_count == 0 { + assert!(prepare(&mut doc).is_empty()); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + } else { + // Kept as read, and named: its opaque data has no DXF form beside the properties. + assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties"))); + assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw)); + } + } + } +} + +#[cfg(test)] +mod string_stream_tests { + use super::*; + use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData}; + use acadrust::types::DxfVersion; + use std::io::Cursor; + + #[test] + fn registered_transport_recovers_separate_non_byte_aligned_strings() { + for version in [DxfVersion::AC1027, DxfVersion::AC1032] { + let mut source = CadDocument::new(); + source.version = version; + source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText")); + let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number); + let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19); + let payload = ProxyPayload::from_bits(&[0x80], 1); + source + .add_entity(EntityType::Extended(Box::new(ExtendedEntity { + common: EntityCommon::default(), + data: ExtendedEntityData::Proxy(ProxyEntityData { + proxy_id: 498, + class_id, + dxf_subclass: "CustomText".into(), + version: 33, + dwg_version: 33, + maintenance_version: 0, + from_dxf: false, + graphics: ProxyPayload::default(), + payload: payload.clone(), + text_payload: text.clone(), + object_ids: vec![], + }), + }))) + .unwrap(); + let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap(); + let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let entity = decoded.entities().next().unwrap(); + let handle = entity.common().handle; + let raw = entity.common().raw_record.clone().unwrap(); + assert_eq!(registered_text(&raw).unwrap().data(), text.data()); + assert_eq!(registered_text(&raw).unwrap().bit_count, 19); + let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() }; + entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData { + dxf_name: "CUSTOM_TEXT".into(), + cpp_class_name: "CustomText".into(), + properties: vec![], + payload: payload.clone(), + object_ids: vec![], + }); + entity.common.raw_record = Some(raw); + assert!(prepare(&mut decoded).is_empty()); + for _ in 0..2 { + let mut dxf = Vec::new(); + acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap(); + let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap(); + decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let p = decoded + .entities() + .find_map(|e| match e { + EntityType::Extended(e) => match &e.data { + ExtendedEntityData::Proxy(p) => Some(p), + _ => None, + }, + _ => None, + }) + .unwrap(); + assert_eq!(p.payload.bit_count, 1); + assert_eq!(p.payload.data(), payload.data()); + assert_eq!(p.text_payload.bit_count, 19); + assert_eq!(p.text_payload.data(), text.data()); + } + } + } + + #[test] + fn invalid_string_stream_boundaries_return_errors() { + for handle_bits in [-1, 1000] { + let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 }; + assert!(registered_text(&raw).is_err()); + } + } +} diff --git a/crates/dwg/src/worker.rs b/crates/dwg/src/worker.rs new file mode 100644 index 000000000..0c273257b --- /dev/null +++ b/crates/dwg/src/worker.rs @@ -0,0 +1,378 @@ +//! The DWG reader and writer in a child process. +//! +//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and +//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says +//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker +//! that reads one request from standard input and writes one reply to standard output. A file that +//! kills the worker then costs that one open or save, and every other open drawing stays safe. +//! +//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`] +//! run in the calling process, as they always did. There's no memory limit on the worker yet: that +//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate. +//! +//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as +//! untrusted input. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use super::Limits; + +/// The argument that makes the app a worker. +pub const WORKER_ARG: &str = "--dwg-worker"; +/// Bumped when the messages change: an app updated while running may start a newer copy. +const PROTOCOL: u8 = 2; +const DECODE: u8 = 1; +const ENCODE: u8 = 2; +const CRASH: u8 = 0xff; +/// How long one file may take before the worker is stopped. It can't be interrupted otherwise. +const TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Most bytes accepted for what a reply carries besides the drawing. +const MAX_NOTES_BYTES: u64 = 64 << 20; +/// Most bytes accepted as a written DWG. +const MAX_DWG_REPLY: u64 = 2 << 30; +/// Most bytes one request holds: the largest input, whichever the direction, and its header. +const MAX_REQUEST: u64 = (2 << 30) + 256; + +struct Worker { + program: PathBuf, + args: Vec, +} + +static WORKER: Mutex> = Mutex::new(None); + +/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`]. +pub fn use_worker(program: PathBuf, args: Vec) { + if let Ok(mut w) = WORKER.lock() { + *w = Some(Worker { program, args }); + } +} + +/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be +/// found, the reader and writer stay in this process. +pub fn use_own_worker() { + match std::env::current_exe() { + Ok(program) => use_worker(program, vec![WORKER_ARG.into()]), + Err(e) => log::warn!("DWG worker: can't find this program: {e}"), + } +} + +/// Serve one request and return the exit code if this process was started as a worker. +pub fn serve_if_asked() -> Option { + (std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock())) +} + +/// [`super::dwg_to_dxf_noted`] in the worker, when there is one. +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + // Before starting a process, and so the same limit holds with or without one. + if bytes.len() > limits.max_dwg_bytes { + return super::dwg_to_dxf_noted(bytes, limits); + } + let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES); + match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") { + Some(reply) => reply?, + None => super::dwg_to_dxf_noted(bytes, limits), + } +} + +/// [`super::dxf_to_dwg_as`] in the worker, when there is one. +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + encode_noted(dxf, version).map(|(dwg, _)| dwg) +} + +/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes +/// back as notes of `TYPEcount`. +pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec, super::Unwritten), String> { + encode_kept_noted(dxf, version, None) +} + +/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept` +/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`]. +pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, super::Unwritten), String> { + if u8::try_from(version.len()).is_err() { + return Err(super::unsupported(version)); + } + // A kept version too long to send is unsupported by this bridge and changes nothing. + let kept = kept.filter(|k| u8::try_from(k.len()).is_ok()); + let mut header = request(ENCODE, Limits::DEFAULT, version); + push_str(&mut header, kept.unwrap_or("")); + match call(&header, dxf, MAX_DWG_REPLY, "writing this file") { + Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))), + None => encode_here(dxf, version, kept), + } +} + +/// The conversion in this process. +fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec, super::Unwritten), String> { + match kept { + Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept), + None => super::dxf_to_dwg_noted(dxf, version), + } +} + +fn unwritten_from(notes: &[String]) -> super::Unwritten { + notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::().ok()?))).fold( + super::Unwritten::new(), + |mut m, (kind, n)| { + *m.entry(kind).or_insert(0) = n; + m + }, + ) +} + +/// Make the worker abort, as the third-party code can: what the tests prove isolation with. +#[doc(hidden)] +pub fn crash_worker_for_test() -> Result<(), String> { + call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ()) +} + +fn request(op: u8, limits: Limits, version: &str) -> Vec { + let mut r = vec![PROTOCOL, op]; + r.extend((limits.max_dwg_bytes as u64).to_le_bytes()); + r.extend((limits.max_dxf_bytes as u64).to_le_bytes()); + push_str(&mut r, version); + r +} + +/// `text` with a one-byte length before it; callers keep it under 256 bytes. +fn push_str(r: &mut Vec, text: &str) { + r.push(u8::try_from(text.len()).unwrap_or(0)); + r.extend(text.as_bytes()); +} + +type Reply = Result<(Vec, Vec), String>; + +/// Run one request in a worker. `None`: none could be started, so the caller does the work. +/// `Err`: the worker went wrong, and `doing` says at what, for the user. +fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option> { + let mut command = { + let worker = WORKER.lock().ok()?; + let worker = worker.as_ref()?; + let mut c = Command::new(&worker.program); + c.args(&worker.args); + c + }; + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + log::warn!("DWG worker: can't start it: {e}"); + return None; + } + }; + let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let received = std::thread::scope(|scope| { + // Written beside the read: a worker that replies before it has read everything, or dies, + // must block neither side. A failed write shows up as the reply going wrong. + scope.spawn(move || { + let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload)); + }); + let (tx, rx) = channel(); + scope.spawn(move || { + let mut reply = Vec::new(); + let read = from_child.take(reply_limit).read_to_end(&mut reply); + let _ = tx.send(read.map(|_| reply)); + }); + let received = rx.recv_timeout(TIMEOUT); + if matches!(received, Err(RecvTimeoutError::Timeout)) { + let _ = child.kill(); + } + received + }); + let status = child.wait(); + let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected"); + Some(match (received, status) { + (Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")), + (Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped), + _ => Err(stopped()), + }) +} + +/// Take `n` bytes off the front of `b`. +fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + let (head, tail) = b.split_at_checked(n)?; + *b = tail; + Some(head) +} + +fn take_u32(b: &mut &[u8]) -> Option { + let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?; + usize::try_from(u32::from_le_bytes(bytes)).ok() +} + +fn take_u64(b: &mut &[u8]) -> Option { + Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?)) +} + +/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1 +/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most +/// of the memory the app may use: they are kept, not copied. +fn parse_reply(mut reply: Vec) -> Option { + let total = reply.len(); + let mut b = &reply[..]; + match *take(&mut b, 1)?.first()? { + 0 => { + let count = take_u32(&mut b)?; + let mut notes = Vec::new(); + for _ in 0..count { + let n = take_u32(&mut b)?; + notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned()); + } + let n = usize::try_from(take_u64(&mut b)?).ok()?; + if b.len() != n { + return None; + } + reply.drain(..total - n); + Some(Ok((reply, notes))) + } + 1 => Some(Err(String::from_utf8_lossy(b).into_owned())), + _ => None, + } +} + +fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> { + match reply { + Ok((bytes, notes)) => { + out.write_all(&[0])?; + out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?; + for note in ¬es { + out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?; + if u32::try_from(note.len()).is_ok() { + out.write_all(note.as_bytes())?; + } + } + out.write_all(&(bytes.len() as u64).to_le_bytes())?; + out.write_all(&bytes)?; + } + Err(e) => { + out.write_all(&[1])?; + out.write_all(e.as_bytes())?; + } + } + out.flush() +} + +/// What a request asks for, or why it can't be served. +fn handle(mut b: &[u8]) -> Reply { + let bad = || "the DWG worker got a request it can't read (a different bridge version?)".to_string(); + if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) { + return Err(bad()); + } + let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + match op { + DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }), + ENCODE => { + // The version the DXF's carried-over groups were read from; empty for none. + let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + encode_here(b, version, (!kept.is_empty()).then_some(kept)) + .map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())) + } + CRASH => std::process::abort(), + _ => Err(bad()), + } +} + +/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent. +fn serve(input: impl Read, mut output: impl Write) -> i32 { + let mut request = Vec::new(); + let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) { + Ok(_) => handle(&request), + Err(e) => Err(format!("the DWG worker could not read its request: {e}")), + }; + i32::from(write_reply(&mut output, reply).is_err()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire(reply: Reply) -> Vec { + let mut out = Vec::new(); + write_reply(&mut out, reply).unwrap(); + out + } + + #[test] + fn what_the_writer_left_out_survives_the_reply() { + let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()]; + assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect()); + } + + #[test] + fn replies_round_trip() { + let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()])); + assert_eq!(parse_reply(wire(ok.clone())), Some(ok)); + let failed: Reply = Err("DWG: no".into()); + assert_eq!(parse_reply(wire(failed.clone())), Some(failed)); + } + + #[test] + fn a_reply_cut_short_or_made_up_is_refused() { + let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()]))); + for n in 0..whole.len() { + assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}"); + } + assert_eq!(parse_reply(vec![7, 0, 0]), None); + // A note count far beyond what follows ends at the first missing note. + assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None); + assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None); + } + + #[test] + fn a_request_it_cannot_read_is_an_error_not_a_crash() { + for request in [ + &[][..], + &[PROTOCOL + 1, DECODE], + &[PROTOCOL], + &[PROTOCOL, DECODE, 1, 2, 3], + &[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] { + assert!(handle(request).is_err(), "{request:?}"); + } + } + + /// An encode request carries the kept version after the target one; a request without it is + /// one it can't read, and one with it reaches the converter. + #[test] + fn an_encode_request_carries_the_kept_version() { + let bad = "the DWG worker got a request it can't read (a different bridge version?)"; + let short = self::request(ENCODE, Limits::DEFAULT, "AC1032"); + assert_eq!(handle(&short).unwrap_err(), bad); + for kept in ["", "AC1032"] { + let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032"); + push_str(&mut request, kept); + request.extend(b"not a drawing"); + let err = handle(&request).unwrap_err(); + assert_ne!(err, bad, "kept {kept:?}"); + } + } + + #[test] + fn serving_reports_a_decode_failure_as_a_reply() { + let mut request = self::request(DECODE, Limits::DEFAULT, ""); + request.extend(b"AC1032 not really a drawing"); + let mut out = Vec::new(); + assert_eq!(serve(&request[..], &mut out), 0); + assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}"); + } +} diff --git a/licenses/dwg/acadrust-MPL-2.0.txt b/licenses/dwg/acadrust-MPL-2.0.txt new file mode 100644 index 000000000..16bfd9043 --- /dev/null +++ b/licenses/dwg/acadrust-MPL-2.0.txt @@ -0,0 +1,374 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under the name "LEGAL", and included in all Covered Software +distributed in Source Code Form. Except to the extent prohibited by +statute or regulation, such description must be sufficiently detailed +for a recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/licenses/dwg/acadrust-THIRD_PARTY_NOTICES.md b/licenses/dwg/acadrust-THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..35fd822bb --- /dev/null +++ b/licenses/dwg/acadrust-THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-Party Notices + +## ACadSharp + +The DWG reader and writer include code ported and adapted from ACadSharp. The repository +history's initial DWG support commit identifies the source classes explicitly, including +`DwgStreamReaderBase`, `DwgLZ77AC21Decompressor`, `DwgFileHeaderWriterAC18`, and +`DwgObjectWriter`. Their current Rust implementations are in `src/io/dwg`. + +Copyright (c) 2021 Albert Domenech + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## DWG Format References + +DWG field layouts and version behavior are based on the Open Design Alliance (ODA) DWG +specification and reverse engineering, including comparison with drawings produced by CAD +applications. LibreDWG is used as a secondary cross-check for selected field names and +layouts. The Reed-Solomon tests cite numeric reference values from LibreDWG's +`src/reedsolomon.c` to validate the Rust implementation; those values are test data, not +translated routines. The repository history and source review found no LibreDWG routines +ported into the implementation. The source comments identify the specific cross-checks.