From 0bc025965ae80d09ecb6ec1ebd487ce901585b64 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Mon, 2 Nov 2026 22:01:21 +0000 Subject: [PATCH] fix(dwg): preserve MTEXT caret-adjacent whitespace (#2250) (#2314) * fix(dwg): preserve literal spaces after MTEXT carets (#2250) * test(dwg): fix MTEXT fixture ownership and boxed entity --------- Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com> --- crates/dwg/src/lib.rs | 95 +++++++++++++++++- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/mtext_caret_spaces.rs | 118 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/it/mtext_caret_spaces.rs diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 037aa26b5..ffde17ee8 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -110,8 +110,11 @@ mod native { 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))) - .map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?; + 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); \ @@ -124,6 +127,94 @@ mod native { 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; diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 7b49eb6bf..289e87806 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -42,6 +42,7 @@ mod mleader_roundtrip; mod mline_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod model_layout_plot_type; +mod mtext_caret_spaces; mod mtext_columns_roundtrip; mod mtext_embedded_object; mod mtext_line_spacing; diff --git a/crates/io/tests/it/mtext_caret_spaces.rs b/crates/io/tests/it/mtext_caret_spaces.rs new file mode 100644 index 000000000..8bb1ed071 --- /dev/null +++ b/crates/io/tests/it/mtext_caret_spaces.rs @@ -0,0 +1,118 @@ +//! Literal MTEXT carets and their adjacent whitespace survive DXF and DWG saves (#2250). + +use cadcraft_doc::{Common, Drawing, EntityKind, Handle, MText, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +type TestResult = Result<(), Box>; + +fn drawing() -> Result<(Drawing, Vec<(Handle, String)>), Box> { + let mut d = Drawing::new_metric(); + let mut expected = Vec::new(); + let mut cases: Vec = [ + "ordinary text", + "^ C", + "A ^ C B", + "^ C", + "A ^ C B", + "^ ", + "^^ C ^ ^ D^", + "^I ^J ^ C", + "\t^ C\nD\tE", + "^\t ^\n C", + r"A\P^ C\P^I ^J", + r"\S+0.1^-0.1; ^ C", + ] + .into_iter() + .map(str::to_string) + .collect(); + // Exercise multiple group-3 chunks and a caret at the output chunk boundary. + cases.push(format!("{}^ C", "A".repeat(124))); + cases.push(format!("{}^ C B", "A".repeat(260))); + for contents in cases { + let kind = EntityKind::MText(MText { + insert: Vec3::new(1.0, 2.0, 0.0), + height: 0.5, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Standard".into(), + contents: contents.clone(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }); + let handle = d.add(&Space::Model, Common::default(), kind)?; + expected.push((handle, contents)); + } + Ok((d, expected)) +} + +fn assert_contents(d: &Drawing, expected: &[(Handle, String)], context: &str) -> TestResult { + assert_eq!(d.model.len(), expected.len(), "{context}: entity count"); + for (handle, contents) in expected { + let e = d.entity(*handle).ok_or("MTEXT handle lost")?; + let EntityKind::MText(t) = &e.kind else { return Err("MTEXT entity type changed".into()) }; + assert_eq!(&t.contents, contents, "{context}: {handle:?}"); + } + Ok(()) +} + +#[test] +fn dxf_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + for binary in [false, true] { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dxf", &SaveOptions { version: Some(version), binary })?; + d = read(&bytes, "carets.dxf")?; + assert_contents(&d, &expected, &format!("{version:?}, binary={binary}, pass={pass}"))?; + } + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?; + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code())); + // Inspect the stored DWG text before its DXF rendition: a compensating reader + // must not hide corruption on save. This uses the same codec, not an external oracle. + let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?; + for (handle, contents) in &expected { + let text = native.entities().find_map(|e| match e { + acadrust::EntityType::MText(t) if t.common.handle.value() == handle.0 => Some(&t.value), + _ => None, + }); + assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}"); + } + let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?; + let converted = read(&dxf, "bridge.dxf")?; + assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?; + d = read(&bytes, "carets.dwg")?; + assert_contents(&d, &expected, &format!("DWG {version:?}, pass={pass}"))?; + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult { + let (d, _) = drawing()?; + let dxf = cadcraft_io::write_dxf(&d); + assert!(dxf.contains(" 1\r\n^ C\r\n"), "literal caret plus authored space needs two wire spaces"); + let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?; + assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C"))); + let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?; + let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?; + assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace"); + Ok(()) +}