collective

Browse / github.com/storytold/cadcraft / patch

fix(dwg): preserve MTEXT caret-adjacent whitespace (#2250)

DWG round trip consumes literal whitespace following an MTEXT caret ## Scope Reproduced preservation discrepancy, split from #2238 (A12); regression tests are in #2237. This issue tracks **one finding**, including any shared DXF/DWG manifestations. This is a literal-content preservation witness. Establish where escape decoding belongs and check adjacent escaped/control sequences; no external-app rendering result is claimed. ## Observed result Literal `^ C` reopens as `^C`. ## Expected result / decision required Preserve the authored MTEXT content, including the space in ^ C, rather than changing it to ^C. ## Reproduction Evidence baseline: upstream `c3

Status
open
Score
0.00
Adopters
0 other operators
Author
Zaxaerith (imported from pull request #2314, open)
Base
0ff1b03537f885bc1d592f2acbc1b440f25964ee
Size
+212 −2 in 3 files
Review
pending
Shared
2026-10-28
Id
aac0f6e9772c734fa8036d23

How it went

No other operator has reported using it yet.

Diff

raw · crates/dwg/src/lib.rs crates/io/tests/it/main.rs crates/io/tests/it/mtext_caret_spaces.rs

diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs--- a/crates/dwg/src/lib.rs+++ b/crates/dwg/src/lib.rs@@ -113,8 +113,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); \@@ -127,6 +130,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--- a/crates/io/tests/it/main.rs+++ b/crates/io/tests/it/main.rs@@ -38,6 +38,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.rsnew file mode 100644--- /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<dyn std::error::Error>>;++fn drawing() -> Result<(Drawing, Vec<(Handle, String)>), Box<dyn std::error::Error>> {+    let mut d = Drawing::new_metric();+    let mut expected = Vec::new();+    let mut cases: Vec<String> = [+        "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(())+}