Open damaged DWGs partially and say what was left out (#800) (#859)

Bump acadrust to 0.6.4 and read DWG in its failsafe mode: an object
record the reader can't decode is skipped instead of failing the whole
file. The drawing's new read_notes (never saved) say how many records
were left out and of which types; OPEN echoes them, returns them as
`warnings`, and the app shows the first in the status bar. A file whose
objects stream can't be read at all is still an error.

acadrust 0.6.4 writes a merged table cell's anchor with 173=0 and the
span, so the DXF reader now takes a merge from the span alone. Unknown
records the DWG writer can't write back are dropped before writing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:30:18 -04:00
1 parent ef9898d57f
commit 1c4d0a543f
11 files changed
+285 -22

No files matched your search

Generated
+34 -6
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "acadrust"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -18,6 +18,7 @@ dependencies = [
"itoa",
"memmap2",
"nom",
"oem_cp",
"once_cell",
"rayon",
"ryu",
@@ -45,7 +46,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
"phf",
"phf 0.13.1",
"serde",
"zvariant",
]
@@ -2708,6 +2709,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[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"
@@ -2801,6 +2811,15 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[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"
@@ -2808,7 +2827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.13.1",
"serde",
]
@@ -2819,7 +2838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.13.1",
]
[[package]]
@@ -2829,12 +2848,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"
+4
View File
@@ -117,6 +117,9 @@ pub struct Drawing {
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
/// a drawing opens with everything shown again.
pub hidden_objects: BTreeSet<Handle>,
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
/// to the user when the drawing opens. Never written to a file.
pub read_notes: Vec<String>,
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
/// written from [`Drawing::committed`].
pub refedit: Option<RefEdit>,
@@ -173,6 +176,7 @@ impl Drawing {
scales: imperial_scales(),
handseed: 0x100,
hidden_objects: BTreeSet::new(),
read_notes: Vec::new(),
refedit: None,
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.3"
acadrust = "0.6.4"
+170 -5
View File
@@ -61,12 +61,30 @@ mod native {
/// 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<Vec<u8>, 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<u8>, Vec<String>), String> {
if bytes.len() > limits.max_dwg_bytes {
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
}
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
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 notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
table_merges(&mut doc);
table_style_rows(&mut doc, false);
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
@@ -81,7 +99,111 @@ mod native {
));
}
written.map_err(|e| format!("DWG→DXF: {e}"))?;
Ok(add_header_vars(out.buf, &doc.header))
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// 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<String> {
let skipped = stats.skipped_source_records;
if skipped == 0 {
return Vec::new();
}
let label = |t: Option<&str>| match t.map(str::parse::<i16>) {
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<String, usize> = 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<String> = 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
@@ -143,6 +265,44 @@ mod native {
}
}
/// 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.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
let version = doc.version;
let unwritable: Vec<acadrust::Handle> = 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 =>
{
Some(u.common.handle)
}
_ => None,
})
.collect();
for h in &unwritable {
doc.remove_entity(*h);
}
let objects: Vec<acadrust::Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { 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 =>
{
Some(*h)
}
_ => None,
})
.collect();
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
}
/// 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] {
@@ -239,6 +399,7 @@ mod native {
None => {}
}
table_style_rows(&mut doc, true);
drop_unwritable(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
@@ -246,7 +407,7 @@ mod native {
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -260,6 +421,10 @@ pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
+19 -4
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("open", "Open...", run_open)
.menu(&["File", "Open..."])
.key("Cmd+O")
.params("{path} | {data: base64, name}")
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
.enabled(always)
.noundo(),
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
@@ -263,7 +263,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
let ReadFile { drawing, binary_dxf, name, path } = f;
let i = add_read(s, drawing, binary_dxf, &name, path);
super::blocks::xref::after_open(s, i);
Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }))
Ok(opened(s, i, true))
}
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
@@ -276,11 +276,15 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
}
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
let notes = drawing.read_notes.clone();
let i = s.open_drawing(drawing, &file_name(name), path);
if let Some(st) = s.docs.get_mut(i) {
// QSAVE keeps a binary DXF binary.
st.binary_dxf = binary_dxf;
}
for note in notes {
s.echo(note);
}
i
}
@@ -289,6 +293,17 @@ pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option
Ok(add_read(s, drawing, binary_dxf, &name, path))
}
/// OPEN's reply: the drawing's index and what reading it left out.
fn opened(s: &Session, i: usize, entities: bool) -> Value {
let d = s.docs.get(i).map(|st| &st.doc);
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
if entities {
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
} else {
json!({ "index": i, "warnings": warnings })
}
}
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(_path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
@@ -298,7 +313,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
return Ok(opened(s, i, true));
}
#[cfg(target_arch = "wasm32")]
return Err(bad("open", "paths are not available on the web; pass `data`"));
@@ -308,7 +323,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
let i = open_bytes(s, &bytes, name, None)?;
super::blocks::xref::after_open(s, i);
Ok(json!({ "index": i }))
Ok(opened(s, i, false))
}
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -58,7 +58,7 @@ pub struct Prompt {
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
/// Returns `None` for any other form.
pub fn parse_polar_text(text: &str, angles: &crate::units::AngleSettings) -> Option<(f64, f64)> {
let body = text.trim().strip_prefix('@')?; // relative form only
let body = text.trim().strip_prefix('@')?; // relative form only
let (d, a) = body.split_once('<')?;
let dist = d.trim().parse::<f64>().ok()?;
let angle = angles.amount(a.trim())?;
+5 -2
View File
@@ -523,8 +523,11 @@ fn acad_table(tags: &[Tag]) -> Table {
let mut text: String = cell.iter().filter(|x| x.code == 2 || x.code == 3).map(Tag::str).collect();
text.push_str(&c.s(1).unwrap_or_default());
let span = |code: i32| c.i(code).and_then(|v| u32::try_from(v).ok());
let merged = match (c.i(173).unwrap_or(0) != 0, span(176), span(175)) {
(true, Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)),
// A merge starts at the cell whose span (rows 176, columns 175) is more than one cell.
// Its own merged flag (173) varies by writer: CADCraft sets it, while AutoCAD-style
// writers (acadrust 0.6.4) set it only on the cells the merge covers, whose span is 0.
let merged = match (span(176), span(175)) {
(Some(rs), Some(cs)) if rs >= 1 && cs >= 1 && (rs, cs) != (1, 1) => Some((rs, cs)),
_ => None,
};
// The cell's own text height: 140 outside the value block (301 … 304), where it is a number.
+7 -1
View File
@@ -1514,7 +1514,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
let committed = d.committed();
let d: &Drawing = &committed;
// Structural handles start above every entity handle.
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(), layers: HashMap::new(), reactors: hatch_reactors(d) };
let mut w = W {
t: Vec::new(),
next: d.handseed.max(0x100).saturating_add(0x1000),
pstyles: HashMap::new(),
layers: HashMap::new(),
reactors: hatch_reactors(d),
};
// Pre-allocate handles for block records.
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
paper_layouts.sort_by_key(|l| l.tab_order);
+4 -2
View File
@@ -59,8 +59,10 @@ fn ext(name: &str) -> String {
/// (SVG and gzip-compressed SVGZ).
pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
if cadcraft_dwg::is_dwg(bytes) {
let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?;
return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")));
let (dxf, notes) = cadcraft_dwg::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
d.read_notes = notes;
return Ok(d);
}
match ext(name).as_str() {
"svg" | "svgz" => svg_read::read(bytes),
+35
View File
@@ -980,6 +980,41 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads");
}
/// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the
/// rest opens, and the drawing's read notes say how many records were left out and of which
/// types, so a partial read never passes for a complete one. A complete read has no notes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn damaged_dwg_opens_partially_and_says_what_it_left_out() {
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
for i in 0..200 {
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
}
dxf += "0\nENDSEC\n0\nEOF\n";
let d = read(dxf.as_bytes(), "lines.dxf").unwrap();
let dwg = crate::write_with(&d, "lines.dwg", &crate::SaveOptions { version: Some(crate::Version::R2000), binary: false }).unwrap();
let whole = read(&dwg, "lines.dwg").unwrap();
assert_eq!((whole.entity_count(), whole.read_notes.len()), (200, 0));
// Damage a few bytes at a time across the objects area: every damaged copy that still reads
// as a DWG either reads whole or notes what it left out.
let mut partial = 0;
for at in (dwg.len() / 4..dwg.len() * 3 / 5).step_by(97) {
let mut b = dwg.clone();
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
let Ok(d) = read(&b, "damaged.dwg") else { continue };
if d.entity_count() < 200 {
assert_eq!(d.read_notes.len(), 1, "{} lines read, no note", d.entity_count());
}
if let [note] = d.read_notes.as_slice() {
assert!(note.starts_with("DWG: ") && note.contains(" couldn't be read and "), "{note}");
assert!(note.ends_with("saving over the original file would lose it") || note.ends_with("would lose them"), "{note}");
partial += 1;
}
}
assert!(partial > 0, "no damaged copy read partially");
}
#[test]
fn dimension_associativity_roundtrips() {
let mut d = Drawing::new_imperial();
+5
View File
@@ -456,6 +456,11 @@ impl CadApp {
if let Some(path) = doc.and_then(|d| d.path.clone()) {
self.remember_recent(path);
}
// A partial read (DWG records left out) shows in the status bar too, not only on
// the command line.
if let Some(w) = v.get("warnings").and_then(Value::as_array).and_then(|w| w.first()).and_then(Value::as_str) {
self.set_status(w.to_string());
}
}
}
}