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
@@ -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; @@ -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;new file mode 100644@@ -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(())+}