collective

Browse / github.com/storytold/cadcraft / patch

DXF 2000/2004: text in the file's code page, as AutoCAD writes it

DXF 2000/2004: text in the file's code page, as AutoCAD writes it Before 2007, a DXF file's text is in the code page its `$DWGCODEPAGE` names. CADCraft wrote pure ASCII instead, with every other character as a `\U+XXXX` escape. AutoCAD decodes those, but older readers don't. The Italian cadastre's **DOCFA**, which imports floor plans from DXF up to AutoCAD 2000, shows `Caff\U+00E8 20\U+00B0` for `Caffè 20°`. A DXF 2000 from CADCraft should simply work there, as one from AutoCAD 2000 does. ## Change - `cadcraft_dxf::write_ascii_bytes(tags, utf8)` writes the text as AutoCAD does: - UTF-8 from 2007; - before 2007, the characters the header's code page has

Status
open
Score
0.00
Adopters
0 other operators
Author
KvasFan (imported from pull request #2170, open)
Base
dfddd13656bde4ed7f6c579d85e34874650bc31a
Size
+192 −18 in 9 files
Review
pending
Shared
2026-10-28
Id
b5ed562a5378cacf7f690b53

How it went

No other operator has reported using it yet.

Diff

raw · crates/dxf/src/codepage.rs crates/dxf/src/lib.rs crates/dxf/src/version.rs crates/io/src/dxf_write.rs crates/io/src/lib.rs crates/io/src/tests.rs crates/io/tests/it/dxf_versions.rs crates/io/tests/it/followups_626.rs docs/file-format-parity.md

diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs--- a/crates/dxf/src/codepage.rs+++ b/crates/dxf/src/codepage.rs@@ -174,6 +174,48 @@ impl BinaryText {     } } +/// How text becomes the bytes of a pre-2007 file's code page (the code pages [`decoder`] knows).+pub(crate) enum Encoder {+    Encoding(&'static Encoding),+    Table(&'static [char; 128]),+}++/// The encoder for a `$DWGCODEPAGE` value, or `None` for UTF-8 and code pages CADCraft doesn't+/// know.+pub(crate) fn encoder(code_page: &str) -> Option<Encoder> {+    match decoder(code_page)? {+        Decoder::Utf8 => None,+        Decoder::Encoding(e) => Some(Encoder::Encoding(e)),+        Decoder::Table(t) => Some(Encoder::Table(t)),+    }+}++impl Encoder {+    /// Append `c` in this code page to `out`; false, with nothing appended, when the code page+    /// has no such character, or its bytes read back as another one (Shift-JIS writes `¥` as+    /// 0x5C, which reads back as a backslash).+    pub(crate) fn push(&self, c: char, out: &mut Vec<u8>) -> bool {+        match self {+            Encoder::Table(t) => match t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) {+                Some(i) => {+                    out.push(0x80 | i);+                    true+                }+                None => false,+            },+            Encoder::Encoding(e) => {+                let mut buf = [0u8; 4];+                let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));+                if unmappable || e.decode_without_bom_handling(&bytes).0.chars().ne(std::iter::once(c)) {+                    return false;+                }+                out.extend_from_slice(&bytes);+                true+            }+        }+    }+}+ /// True for an `$ACADVER` of R2007 (AC1021) or later, whose text is UTF-8. fn is_utf8_version(acadver: &str) -> bool {     acadver.trim().strip_prefix("AC").and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1021)diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs--- a/crates/dxf/src/lib.rs+++ b/crates/dxf/src/lib.rs@@ -424,6 +424,70 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {     out } +/// Write tags as ASCII DXF bytes, with text as AutoCAD writes it: UTF-8 from 2007 (`utf8`), and+/// before 2007 in the code page the tags' `$DWGCODEPAGE` names, characters outside it as+/// `\U+XXXX`. Older readers that don't decode the escapes (the Italian cadastre's DOCFA, CNC and+/// plotter front ends) then show `è`, not `\U+00E8`. Without a code page CADCraft knows, the text+/// is pure ASCII with escapes, as [`write_ascii`] writes it.+pub fn write_ascii_bytes(tags: &[Tag], utf8: bool) -> Vec<u8> {+    if utf8 {+        return write_ascii_as(tags, true).into_bytes();+    }+    let Some(enc) = header_code_page(tags).and_then(codepage::encoder) else { return write_ascii_as(tags, false).into_bytes() };+    let mut out = Vec::with_capacity(tags.len() * 16);+    for t in tags {+        match &t.value {+            Value::Str(s) if !s.is_ascii() => {+                out.extend_from_slice(format!("{:>3}\r\n", t.code).as_bytes());+                encode_string(s, &enc, &mut out);+                out.extend_from_slice(b"\r\n");+            }+            _ => out.extend_from_slice(write_ascii_as(std::slice::from_ref(t), false).as_bytes()),+        }+    }+    out+}++/// The `$DWGCODEPAGE` value among the tags (the group 3 after the group 9 that names it).+fn header_code_page(tags: &[Tag]) -> Option<&str> {+    let i = tags.iter().position(|t| t.code == 9 && matches!(&t.value, Value::Str(s) if s == "$DWGCODEPAGE"))?;+    match tags.get(i + 1) {+        Some(Tag { code: 3, value: Value::Str(s) }) => Some(s.as_str()),+        _ => None,+    }+}++/// A string in a pre-2007 file's code page: as [`escape_unicode`] writes it, but with the+/// characters the code page has as its bytes. A string whose bytes would also be valid UTF-8 is+/// escaped instead: the reader takes a file that is valid UTF-8 as UTF-8, so every line with a+/// code page byte must not be.+fn encode_string(s: &str, enc: &codepage::Encoder, out: &mut Vec<u8>) {+    let chars: Vec<char> = s.chars().collect();+    let mut bytes = Vec::with_capacity(s.len());+    for (i, &c) in chars.iter().enumerate() {+        match c {+            '\r' | '\n' => bytes.push(b' '),+            '\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => bytes.extend_from_slice(b"\\U+005C"),+            c if c.is_ascii() => bytes.extend_from_slice(c.encode_utf8(&mut [0u8; 4]).as_bytes()),+            c => {+                if !enc.push(c, &mut bytes) {+                    let mut units = [0u16; 2];+                    for u in c.encode_utf16(&mut units) {+                        bytes.extend_from_slice(format!("\\U+{u:04X}").as_bytes());+                    }+                }+            }+        }+    }+    if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {+        let mut escaped = String::new();+        escape_unicode(s, false, &mut escaped);+        out.extend_from_slice(escaped.as_bytes());+    } else {+        out.extend_from_slice(&bytes);+    }+}+ /// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel, /// then per tag a 2-byte little-endian group code and the value in the binary form of the code's /// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte@@ -568,6 +632,54 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> { mod tests {     use super::*; +    /// Pre-2007 text is written in the header's code page, as AutoCAD writes it, and reads back.+    #[test]+    fn pre_2007_text_is_written_in_the_code_page() {+        let file = |code_page: &str, text: &str| {+            vec![+                Tag::s(0, "SECTION"),+                Tag::s(2, "HEADER"),+                Tag::s(9, "$ACADVER"),+                Tag::s(1, "AC1015"),+                Tag::s(9, "$DWGCODEPAGE"),+                Tag::s(3, code_page),+                Tag::s(0, "ENDSEC"),+                Tag::s(0, "SECTION"),+                Tag::s(2, "ENTITIES"),+                Tag::s(0, "TEXT"),+                Tag::s(1, text),+                Tag::s(0, "ENDSEC"),+                Tag::s(0, "EOF"),+            ]+        };+        let cases: [(&str, &str, &[u8]); 5] = [+            ("ANSI_1252", "Caffè 20° – ±1", b"Caff\xe8 20\xb0 \x96 \xb11"),+            ("ANSI_1252", "图 è", b"\\U+56FE \xe8"),+            ("ANSI_1251", "Київ", b"\xca\xe8\xbf\xe2"),+            ("DOS850", "è", b"\x8a"),+            // Bytes that would read as UTF-8 ("é" in Windows-1252 is UTF-8 for "é"): escaped.+            ("ANSI_1252", "é", b"\\U+00C3\\U+00A9"),+        ];+        for (code_page, text, expected) in cases {+            let tags = file(code_page, text);+            let bytes = write_ascii_bytes(&tags, false);+            let line = bytes.split(|&b| b == b'\n').map(<[u8]>::trim_ascii).find(|l| l.starts_with(&expected[..1]) && l.len() == expected.len());+            assert_eq!(line, Some(expected), "{code_page} {text}");+            assert_eq!(parse(&bytes).unwrap(), tags, "{code_page} {text} reads back");+        }+        // Shift-JIS encodes ¥ as 0x5C, which reads back as a backslash: a character whose bytes+        // don't decode to it again is escaped.+        let tags = file("ANSI_932", "¥図");+        assert_eq!(parse(&write_ascii_bytes(&tags, false)).unwrap(), tags, "¥ survives Shift-JIS");+        // A literal escape stays literal; no code page CADCraft knows: pure ASCII; 2007+: UTF-8.+        let tags = file("ANSI_1252", "\\U+00E9 è");+        assert_eq!(parse(&write_ascii_bytes(&tags, false)).unwrap(), tags);+        assert_eq!(write_ascii_bytes(&file("NOPE", "è"), false), write_ascii(&file("NOPE", "è")).into_bytes());+        assert_eq!(write_ascii_bytes(&file("ANSI_1252", "è"), true), write_ascii_as(&file("ANSI_1252", "è"), true).into_bytes());+        // ASCII text is written exactly as before.+        assert_eq!(write_ascii_bytes(&file("ANSI_1252", "plain"), false), write_ascii(&file("ANSI_1252", "plain")).into_bytes());+    }+     #[test]     fn literal_escape_text_roundtrips() {         for utf8 in [false, true] {diff --git a/crates/dxf/src/version.rs b/crates/dxf/src/version.rs--- a/crates/dxf/src/version.rs+++ b/crates/dxf/src/version.rs@@ -75,7 +75,7 @@ impl Version {     }      /// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in-    /// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.+    /// the `$DWGCODEPAGE` code page, characters outside it as `\U+XXXX` escapes.     pub fn utf8(self) -> bool {         self >= Version::R2007     }diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs--- a/crates/io/src/dxf_write.rs+++ b/crates/io/src/dxf_write.rs@@ -1234,9 +1234,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx     } } -/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text-/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one);-/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`.+/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the+/// text is written in it, characters outside it as `\U+XXXX` escapes); otherwise, and from 2007+/// (UTF-8 text), `ANSI_1252`. fn code_page(d: &Drawing, version: Version) -> String {     let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default();     let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs--- a/crates/io/src/lib.rs+++ b/crates/io/src/lib.rs@@ -88,11 +88,7 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>     match ext(name).as_str() {         "dxf" | "" => {             let tags = dxf_write::tags(d, version);-            Ok(if opts.binary {-                cadcraft_dxf::write_binary(&tags, version.utf8())-            } else {-                cadcraft_dxf::write_ascii_as(&tags, version.utf8()).into_bytes()-            })+            Ok(if opts.binary { cadcraft_dxf::write_binary(&tags, version.utf8()) } else { cadcraft_dxf::write_ascii_bytes(&tags, version.utf8()) })         }         // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without         // one, at least 2004: R2000 DWG has no true colours, transparency, table styles ordiff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs--- a/crates/io/src/tests.rs+++ b/crates/io/src/tests.rs@@ -1551,6 +1551,25 @@ fn non_ascii_text_is_escaped_for_r2000_and_roundtrips() {     assert_eq!(write_dxf(&back).matches("\\U+").count(), text.matches("\\U+").count()); } +/// A DXF saved at 2000 or 2004 has its text in its code page, as AutoCAD writes it: the Latin-1+/// characters of `ANSI_1252` as single bytes (older readers such as the Italian cadastre's DOCFA+/// show `\U+00C4` literally), the others as `\U+XXXX`. It reads back the same.+#[test]+fn a_2000_save_writes_text_in_its_code_page_and_roundtrips() {+    let d = intl_sample();+    for version in [crate::Version::R2000, crate::Version::R2004] {+        let bytes = crate::write_with(&d, "intl.dxf", &crate::SaveOptions { version: Some(version), binary: false }).unwrap();+        assert!(std::str::from_utf8(&bytes).is_err(), "{version}: code page bytes, not UTF-8");+        let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);+        assert!(has(b"\\U+56FE\\U+53F7 \xc4\xb0\xf8 \xd8 \\U+D83D\\U+DE00"), "{version}: CJK and astral escaped, Latin-1 as bytes");+        assert!(!has(b"\\U+00C4"));+        assert_intl(&read_dxf(&bytes).unwrap());+    }+    // 2007 and later stay UTF-8.+    let bytes = crate::write_with(&d, "intl.dxf", &crate::SaveOptions { version: Some(crate::Version::R2018), binary: false }).unwrap();+    assert!(std::str::from_utf8(&bytes).is_ok_and(|t| t.contains("图号 İø Ø")));+}+ /// Real R2007+ files are UTF-8 and older ones are in their code page; both still read. #[test] fn reads_utf8_and_code_page_text() {diff --git a/crates/io/tests/it/dxf_versions.rs b/crates/io/tests/it/dxf_versions.rs--- a/crates/io/tests/it/dxf_versions.rs+++ b/crates/io/tests/it/dxf_versions.rs@@ -87,9 +87,9 @@ fn dxf_at_every_version_reads_back_the_same() {             assert_eq!(acadver(&bytes), v.code(), "{what}");             assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}");             let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes());-            assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, \\U+ escapes before");+            assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page (CJK as \\U+ escapes) before");             if !v.utf8() && !binary {-                assert!(bytes.is_ascii(), "{what}");+                assert!(std::str::from_utf8(&bytes).is_err(), "{what}: Latin-1 text as ANSI_1252 bytes, as AutoCAD writes it");             }             let back = read(&bytes, "x.dxf").unwrap();             assert_eq!(first_text(&back).as_deref(), Some(INTL), "{what}");@@ -99,11 +99,16 @@ fn dxf_at_every_version_reads_back_the_same() {     } } -/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote.+/// R2000 ASCII holds exactly the tags of the R2000 DXF CADCraft always wrote; only its non-ASCII+/// text is now in the code page instead of escaped. #[test] fn r2000_ascii_is_unchanged() {     let d = sample();-    assert_eq!(write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&d).into_bytes());+    let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap();+    assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(write_dxf(&d).as_bytes()).unwrap());+    let mut ascii = Drawing::new_metric();+    ascii.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) })).unwrap();+    assert_eq!(write_with(&ascii, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&ascii).into_bytes(), "ASCII text: byte for byte"); }  /// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (thediff --git a/crates/io/tests/it/followups_626.rs b/crates/io/tests/it/followups_626.rs--- a/crates/io/tests/it/followups_626.rs+++ b/crates/io/tests/it/followups_626.rs@@ -177,10 +177,9 @@ fn code_page_is_kept_before_2007() {     .unwrap();     for v in [Version::R2000, Version::R2004] {         let bytes = save(&d, "x.dxf", v);-        assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes");-        let text = String::from_utf8(bytes).unwrap();-        assert_eq!(code_page(&text), "ANSI_1251", "{v:?}");-        let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();+        assert!(bytes.windows(6).any(|w| w == b"\xcf\xf0\xe8\xe2\xe5\xf2"), "{v:?}: the text in ANSI_1251, as AutoCAD writes it");+        assert_eq!(code_page(&String::from_utf8_lossy(&bytes)), "ANSI_1251", "{v:?}");+        let back = cadcraft_io::read_dxf(&bytes).unwrap();         assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251");         assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет")));     }diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (2000/2004 DXF text in the file's code page, as AutoCAD writes it) · **Target:** Autodesk AutoCAD 2027  Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds@@ -140,6 +140,7 @@ our output in AutoCAD black-box.  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | DXF save at 2000 and 2004: text is written in the file's `$DWGCODEPAGE` (`è`, `°` as `ANSI_1252` bytes), as AutoCAD writes it, with `\U+XXXX` only for characters outside the code page; older readers that don't decode the escapes, such as the Italian cadastre's DOCFA, showed `\U+00E8`. DWG 2000/2004 already stored the code page text | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |