From 006e4df225fca57e95ead0fb1f91920900798a99 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 20:25:03 -0500 Subject: [PATCH] Add craft-dxf: DXF tag reader and writer, from CADCraft (#796) Moved from cadcraft-dxf (cadcraft 2f8f1861) unchanged apart from names and docs: ASCII and binary DXF as group-code tags, sections and entities, pre-2007 code pages, versions, and writing. VectorCraft's hand-written DXF writer can move onto it next. Part of #778, #791. --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 4 + crates/dxf/Cargo.toml | 16 + crates/dxf/src/codepage.rs | 398 ++++++++++++ crates/dxf/src/lib.rs | 925 ++++++++++++++++++++++++++++ crates/dxf/src/version.rs | 117 ++++ crates/dxf/tests/ascii_bom.rs | 19 + crates/dxf/tests/binary_write.rs | 75 +++ crates/dxf/tests/code_page_write.rs | 116 ++++ crates/dxf/tests/conformance.rs | 145 +++++ crates/dxf/tests/hardening.rs | 220 +++++++ crates/dxf/tests/hex_chunks.rs | 119 ++++ crates/dxf/tests/roundtrip.rs | 148 +++++ 14 files changed, 2312 insertions(+) create mode 100644 crates/dxf/Cargo.toml create mode 100644 crates/dxf/src/codepage.rs create mode 100644 crates/dxf/src/lib.rs create mode 100644 crates/dxf/src/version.rs create mode 100644 crates/dxf/tests/ascii_bom.rs create mode 100644 crates/dxf/tests/binary_write.rs create mode 100644 crates/dxf/tests/code_page_write.rs create mode 100644 crates/dxf/tests/conformance.rs create mode 100644 crates/dxf/tests/hardening.rs create mode 100644 crates/dxf/tests/hex_chunks.rs create mode 100644 crates/dxf/tests/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 52a4ae61..b77c1116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -875,6 +875,15 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-dxf" +version = "0.1.0" +dependencies = [ + "encoding_rs", + "log", + "thiserror 2.0.21", +] + [[package]] name = "craft-ecproj-format" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 782dfa70..3177250c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-crw = { path = "crates/crw" } craft-dialogs = { path = "crates/dialogs" } +craft-dxf = { path = "crates/dxf" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-ecproj-format = { path = "crates/ecproj-format" } diff --git a/README.md b/README.md index 436aba39..6b64e33e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Option { + use encoding_rs as e; + let name = code_page.trim().to_ascii_uppercase().replace(['-', ' '], "_"); + let enc = match name.as_str() { + "UTF8" | "UTF_8" | "UNICODE" => return Some(Decoder::Utf8), + "ASCII" | "ANSI_1252" | "ISO8859_1" | "ISO_8859_1" => e::WINDOWS_1252, + "ANSI_874" => e::WINDOWS_874, + "ANSI_932" | "DOS932" | "SHIFT_JIS" => e::SHIFT_JIS, + "ANSI_936" | "GB2312" | "GBK" => e::GBK, + "ANSI_949" | "KSC5601" | "KOREAN" => e::EUC_KR, + "ANSI_950" | "BIG5" => e::BIG5, + "ANSI_1250" => e::WINDOWS_1250, + "ANSI_1251" => e::WINDOWS_1251, + "ANSI_1253" => e::WINDOWS_1253, + "ANSI_1254" | "ISO8859_9" | "ISO_8859_9" => e::WINDOWS_1254, + "ANSI_1255" => e::WINDOWS_1255, + "ANSI_1256" => e::WINDOWS_1256, + "ANSI_1257" => e::WINDOWS_1257, + "ANSI_1258" => e::WINDOWS_1258, + "ISO8859_2" | "ISO_8859_2" => e::ISO_8859_2, + "ISO8859_3" | "ISO_8859_3" => e::ISO_8859_3, + "ISO8859_4" | "ISO_8859_4" => e::ISO_8859_4, + "ISO8859_5" | "ISO_8859_5" => e::ISO_8859_5, + "ISO8859_6" | "ISO_8859_6" => e::ISO_8859_6, + "ISO8859_7" | "ISO_8859_7" => e::ISO_8859_7, + "ISO8859_8" | "ISO_8859_8" => e::ISO_8859_8, + "DOS866" => e::IBM866, + "MACINTOSH" | "MAC_ROMAN" => e::MACINTOSH, + "DOS437" => return Some(Decoder::Table(&DOS437)), + "DOS850" => return Some(Decoder::Table(&DOS850)), + "DOS852" => return Some(Decoder::Table(&DOS852)), + _ => return None, + }; + Some(Decoder::Encoding(enc)) +} + +/// Decode `bytes` from the code page `code_page` names (a `$DWGCODEPAGE` value such as +/// `ANSI_1251`), or `None` when CADCraft doesn't know that code page. Invalid sequences become +/// U+FFFD; escapes (`\U+XXXX`, `\M+nXXXX`) are left as they are. +pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option { + decoder(code_page).map(|d| decode(bytes, d).into_owned()) +} + +/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes: +/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as +/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007 +/// reader expects. +pub(crate) fn encoder(code_page: &str) -> Option { + if code_page.trim().eq_ignore_ascii_case("ASCII") { + return None; + } + decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8)) +} + +/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when +/// the code page has it; otherwise append nothing and return false (the caller escapes it). +pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec) -> bool { + let mut buf = [0; 4]; + match d { + // Not a pre-2007 code page ([`encoder`] never gives it): escaped. + Decoder::Utf8 => return false, + Decoder::Encoding(e) => { + let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf)); + // A code page byte that is ASCII would read back as another character, and a NUL, + // CR or LF would break the group, so only all-high-bit sequences or proper double-byte + // characters are kept (their trail byte may be ASCII, as in Shift-JIS). + if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) { + return false; + } + out.extend_from_slice(&bytes); + } + Decoder::Table(t) => { + let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false }; + out.push(0x80 | i); + } + } + true +} + +/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252. +pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> { + match d { + Decoder::Utf8 => match std::str::from_utf8(bytes) { + Ok(s) => Cow::Borrowed(s), + Err(_) => encoding_rs::WINDOWS_1252.decode_without_bom_handling(bytes).0, + }, + Decoder::Encoding(e) => e.decode_without_bom_handling(bytes).0, + Decoder::Table(t) => match std::str::from_utf8(bytes) { + Ok(s) if s.is_ascii() => Cow::Borrowed(s), + _ => Cow::Owned( + bytes + .iter() + .map(|&b| if b.is_ascii() { char::from(b) } else { t.get(usize::from(b & 0x7F)).copied().unwrap_or('\u{FFFD}') }) + .collect(), + ), + }, + } +} + +/// The `$DWGCODEPAGE` value of an ASCII DXF file: the line two lines after a `$DWGCODEPAGE` +/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops +/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded. +fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> { + let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty()); + while let Some(line) = lines.next() { + if line == b"ENDSEC" { + return None; + } + if line.eq_ignore_ascii_case(b"$DWGCODEPAGE") { + return match (lines.next(), lines.next()) { + (Some(b"3"), Some(value)) => Some(value), + _ => None, + }; + } + } + None +} + +impl Decoder { + /// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5). + /// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for + /// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]). + fn is_east_asian(self) -> bool { + use encoding_rs as e; + matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5) + } +} + +/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII +/// text, and older files from writers that ignored the code page); anything else comes from the +/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use +/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read +/// from the code page (see [`BinaryText::string`]). +pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> { + match std::str::from_utf8(bytes) { + Ok(s) => Cow::Borrowed(s), + Err(_) => decode(bytes, d), + } +} + +/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for +/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that +/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual +/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is +/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from +/// that code page, because a short double-byte string (two half-width katakana, many kanji +/// pairs) can be valid UTF-8 by accident and must not be misread line by line. +pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> { + if let Ok(s) = std::str::from_utf8(bytes) { + return Cow::Borrowed(s); + } + let d = match ascii_code_page(bytes) { + Some(name) => { + let name = String::from_utf8_lossy(name); + decoder(&name).unwrap_or_else(|| { + log::warn!("DXF: unknown code page `{name}`; reading the text as ANSI_1252"); + Decoder::Utf8 + }) + } + None => Decoder::Utf8, + }; + if d.is_east_asian() { + return decode(bytes, d); + } + // Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside + // a multi-byte character of the supported code pages) and decode each line on its own. + let mut out = String::with_capacity(bytes.len()); + for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') { + out.push_str(&decode_text(line, d)); + } + Cow::Owned(out) +} + +/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER` +/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come +/// first in the HEADER section and are ASCII, so every later string is decoded right. +#[derive(Default)] +pub(crate) struct BinaryText { + /// The header variable whose value comes next (after a group 9). + var: Option, + utf8: bool, + code_page: Option, + /// Decode every string of an East Asian code page file from the code page, never as UTF-8 + /// (the second pass of [`BinaryText::needs_code_page_pass`]). + code_page_only: bool, + /// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8. + ea_utf8: bool, + ea_other: bool, +} + +#[derive(Clone, Copy, PartialEq)] +enum HeaderVar { + AcadVer, + CodePage, +} + +impl BinaryText { + /// The state for the second pass over a file whose strings are a mix of valid UTF-8 and + /// not: every string of an East Asian code page is decoded from that code page. + pub(crate) fn code_page_pass() -> Self { + Self { code_page_only: true, ..Self::default() } + } + + /// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are + /// not all valid UTF-8 although some are: those can be short double-byte strings that look + /// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A + /// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.) + pub(crate) fn needs_code_page_pass(&self) -> bool { + !self.code_page_only && self.ea_utf8 && self.ea_other + } + + /// Decode one string value of group `code` and update the state from it. Strings of R2007+ + /// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a + /// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so + /// they are read from the code page even when their bytes are valid UTF-8 by accident — + /// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken + /// as UTF-8 (see [`Self::needs_code_page_pass`]). + pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String { + let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) }; + let s = if d.is_east_asian() && !self.code_page_only { + if !raw.is_ascii() { + if std::str::from_utf8(raw).is_ok() { + self.ea_utf8 = true; + } else { + self.ea_other = true; + } + } + decode_text(raw, d).into_owned() + } else { + decode(raw, d).into_owned() + }; + match (code, self.var.take()) { + (9, _) => { + self.var = match s.trim() { + "$ACADVER" => Some(HeaderVar::AcadVer), + "$DWGCODEPAGE" => Some(HeaderVar::CodePage), + _ => None, + } + } + (1, Some(HeaderVar::AcadVer)) => self.utf8 = is_utf8_version(&s), + (3, Some(HeaderVar::CodePage)) => { + self.code_page = decoder(&s); + if self.code_page.is_none() { + log::warn!("DXF: unknown code page `{}`; reading the text as ANSI_1252", s.trim()); + } + } + _ => {} + } + s + } +} + +/// 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::().ok()).is_some_and(|n| n >= 1021) +} + +/// The double-byte code page of a `\M+n` escape: 1 Japanese (Shift-JIS), 2 Traditional Chinese +/// (Big5), 3 Korean (Wansung), 5 Simplified Chinese (GB 2312). 4 (Korean Johab) is not +/// supported, so its escapes stay literal. +pub(crate) fn mif_encoding(n: char) -> Option<&'static Encoding> { + match n { + '1' => Some(encoding_rs::SHIFT_JIS), + '2' => Some(encoding_rs::BIG5), + '3' => Some(encoding_rs::EUC_KR), + '5' => Some(encoding_rs::GBK), + _ => None, + } +} + +// Generated from the IBM PC code page definitions (bytes 0x80–0xFF in order). +const DOS437: [char; 128] = [ + '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', + '\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}', + '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', + '\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}', + '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}', + '\u{255D}', '\u{255C}', '\u{255B}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}', '\u{255F}', + '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}', + '\u{2558}', '\u{2552}', '\u{2553}', '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}', '\u{2580}', + '\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}', '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}', + '\u{221E}', '\u{03C6}', '\u{03B5}', '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}', '\u{2248}', + '\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}', '\u{00A0}', +]; +const DOS850: [char; 128] = [ + '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}', + '\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}', + '\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00F8}', '\u{00A3}', '\u{00D8}', '\u{00D7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', + '\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{00AE}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}', + '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{00C0}', '\u{00A9}', '\u{2563}', '\u{2551}', '\u{2557}', + '\u{255D}', '\u{00A2}', '\u{00A5}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{00E3}', '\u{00C3}', + '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{00F0}', '\u{00D0}', '\u{00CA}', '\u{00CB}', + '\u{00C8}', '\u{0131}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{00A6}', '\u{00CC}', '\u{2580}', + '\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{00D2}', '\u{00F5}', '\u{00D5}', '\u{00B5}', '\u{00FE}', '\u{00DE}', '\u{00DA}', '\u{00DB}', '\u{00D9}', + '\u{00FD}', '\u{00DD}', '\u{00AF}', '\u{00B4}', '\u{00AD}', '\u{00B1}', '\u{2017}', '\u{00BE}', '\u{00B6}', '\u{00A7}', '\u{00F7}', '\u{00B8}', + '\u{00B0}', '\u{00A8}', '\u{00B7}', '\u{00B9}', '\u{00B3}', '\u{00B2}', '\u{25A0}', '\u{00A0}', +]; +const DOS852: [char; 128] = [ + '\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{016F}', '\u{0107}', '\u{00E7}', '\u{0142}', '\u{00EB}', '\u{0150}', '\u{0151}', + '\u{00EE}', '\u{0179}', '\u{00C4}', '\u{0106}', '\u{00C9}', '\u{0139}', '\u{013A}', '\u{00F4}', '\u{00F6}', '\u{013D}', '\u{013E}', '\u{015A}', + '\u{015B}', '\u{00D6}', '\u{00DC}', '\u{0164}', '\u{0165}', '\u{0141}', '\u{00D7}', '\u{010D}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}', + '\u{0104}', '\u{0105}', '\u{017D}', '\u{017E}', '\u{0118}', '\u{0119}', '\u{00AC}', '\u{017A}', '\u{010C}', '\u{015F}', '\u{00AB}', '\u{00BB}', + '\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{011A}', '\u{015E}', '\u{2563}', '\u{2551}', '\u{2557}', + '\u{255D}', '\u{017B}', '\u{017C}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{0102}', '\u{0103}', + '\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{0111}', '\u{0110}', '\u{010E}', '\u{00CB}', + '\u{010F}', '\u{0147}', '\u{00CD}', '\u{00CE}', '\u{011B}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{0162}', '\u{016E}', '\u{2580}', + '\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{0143}', '\u{0144}', '\u{0148}', '\u{0160}', '\u{0161}', '\u{0154}', '\u{00DA}', '\u{0155}', '\u{0170}', + '\u{00FD}', '\u{00DD}', '\u{0163}', '\u{00B4}', '\u{00AD}', '\u{02DD}', '\u{02DB}', '\u{02C7}', '\u{02D8}', '\u{00A7}', '\u{00F7}', '\u{00B8}', + '\u{00B0}', '\u{00A8}', '\u{02D9}', '\u{0171}', '\u{0158}', '\u{0159}', '\u{25A0}', '\u{00A0}', +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_page_names() { + let d = |bytes: &[u8], cp: &str| decode_code_page(bytes, cp); + assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", "ANSI_1251").as_deref(), Some("Привет")); + assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", " ansi_1251 ").as_deref(), Some("Привет")); + assert_eq!(d(b"\x90\x7D\x96\xCA", "ANSI_932").as_deref(), Some("図面")); + assert_eq!(d(b"\xCD\xBC\xB2\xE3", "GB2312").as_deref(), Some("图层")); + assert_eq!(d(b"\xB9\xCF\xBC\x68", "BIG5").as_deref(), Some("圖層")); + assert_eq!(d(b"\xB5\xB5\xB8\xE9", "ANSI_949").as_deref(), Some("도면")); + assert_eq!(d(b"\xD3\xF7\xDD\xE4\xE9\xEF", "ANSI_1253").as_deref(), Some("Σχέδιο")); + assert_eq!(d(b"\xC0\xD2\xBE", "ANSI_874").as_deref(), Some("ภาพ")); + assert_eq!(d(b"\x80", "ANSI_1252").as_deref(), Some("€")); + // DOS 437 and 850 agree on letters but differ on 0x9B (¢ / ø). + assert_eq!(d(b"Gr\x94\xE1e \x9B", "DOS437").as_deref(), Some("Größe ¢")); + assert_eq!(d(b"Gr\x94\xE1e \x9B", "dos850").as_deref(), Some("Größe ø")); + assert_eq!(d(b"\xBD\xA2\x88w", "DOS852").as_deref(), Some("Żółw")); + assert_eq!(d(b"\x91\xAB\xAE\xA9", "DOS866").as_deref(), Some("Слой")); + assert_eq!(d("é".as_bytes(), "UTF8").as_deref(), Some("é")); + assert_eq!(d(b"x", "ANSI_1361"), None); + assert_eq!(d(b"x", "EBCDIC"), None); + assert_eq!(d(b"x", ""), None); + } + + #[test] + fn invalid_sequences_become_replacement_characters() { + // A Shift-JIS lead byte cut off by the line end keeps the line end. + assert_eq!(decode_code_page(b"\x90\n1\n", "ANSI_932").as_deref(), Some("\u{FFFD}\n1\n")); + assert_eq!(decode_code_page(b"\x90", "ANSI_932").as_deref(), Some("\u{FFFD}")); + assert_eq!(decode_code_page(b"\xFF\xFF", "ANSI_936").as_deref(), Some("\u{FFFD}\u{FFFD}")); + } + + #[test] + fn header_scan() { + let head = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\r\n0\nENDSEC\n"; + assert_eq!(ascii_code_page(head), Some(&b"ANSI_1251"[..])); + // Only the HEADER section counts. + assert_eq!(ascii_code_page(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n9\n$DWGCODEPAGE\n3\nANSI_1251\n"), None); + // Wrong group code, or the file ends early. + assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n1\nANSI_1251\n"), None); + assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n3"), None); + assert!(is_utf8_version("AC1021") && is_utf8_version(" AC1032 ")); + assert!(!is_utf8_version("AC1018") && !is_utf8_version("AC1009") && !is_utf8_version("garbage")); + } + + #[test] + fn mif_escapes() { + let one = |s: &str| crate::parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap()[0].str(); + assert_eq!(one("\\M+5BCFE\\M+1907D\\M+2B9CF\\M+3B5B5!"), "件図圖도!"); + assert_eq!(one("\\M+5BCFE\\U+0021"), "件!"); + // Johab, unknown code pages, bad hex, short escapes and unassigned codes stay literal. + for s in ["\\M+4ABCD", "\\M+9BCFE", "\\M+5GGGG", "\\M+5BC", "\\M+", "\\M+50041", "\\M+5FFFF"] { + assert_eq!(one(s), s); + } + } +} diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs new file mode 100644 index 00000000..286219fc --- /dev/null +++ b/crates/dxf/src/lib.rs @@ -0,0 +1,925 @@ +//! A standalone DXF tag reader and writer. +//! +//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a +//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping +//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF +//! Reference; depends on no other Craft crate. Moved from CADCraft's `cadcraft-dxf`. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::fmt::Write as _; +use std::io::Write as _; + +mod version; +pub use version::Version; + +#[derive(Debug, thiserror::Error)] +pub enum DxfError { + #[error("not a DXF file")] + NotDxf, + #[error("line {line}: bad group code `{text}`")] + BadCode { line: usize, text: String }, + #[error("unexpected end of file")] + Eof, + /// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the + /// limit was reached), `limit` the maximum. + #[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")] + TooLarge { tags: usize, limit: usize }, + /// The memory for `tags` group codes couldn't be had: an error, not the process ending. + #[error("not enough memory to read this drawing's {tags} DXF group codes")] + OutOfMemory { tags: usize }, +} + +pub type Result = std::result::Result; + +/// A typed group value. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Str(String), + Real(f64), + Int(i64), + Bool(bool), + /// Binary chunk (310–319, 1004) as hex text. + Hex(String), +} + +impl Value { + pub fn as_str(&self) -> String { + match self { + Value::Str(s) | Value::Hex(s) => s.clone(), + Value::Real(r) => format_real(*r), + Value::Int(i) => i.to_string(), + Value::Bool(b) => i64::from(*b).to_string(), + } + } + pub fn as_f64(&self) -> f64 { + match self { + Value::Real(r) if r.is_finite() => *r, + Value::Int(i) => *i as f64, + Value::Str(s) => s.trim().parse::().ok().filter(|v| v.is_finite()).unwrap_or(0.0), + Value::Bool(b) => f64::from(u8::from(*b)), + _ => 0.0, + } + } + pub fn as_i64(&self) -> i64 { + match self { + Value::Int(i) => *i, + Value::Real(r) if r.is_finite() => *r as i64, + Value::Str(s) => { + s.trim().parse::().unwrap_or_else(|_| s.trim().parse::().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64)) + } + Value::Bool(b) => i64::from(*b), + _ => 0, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Tag { + pub code: i32, + pub value: Value, +} + +impl Tag { + pub fn new(code: i32, value: Value) -> Self { + Tag { code, value } + } + pub fn s(code: i32, v: impl Into) -> Self { + Tag { code, value: Value::Str(v.into()) } + } + pub fn f(code: i32, v: f64) -> Self { + Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) } + } + pub fn i(code: i32, v: i64) -> Self { + Tag { code, value: Value::Int(v) } + } + pub fn str(&self) -> String { + self.value.as_str() + } + pub fn f64(&self) -> f64 { + self.value.as_f64() + } + pub fn i64(&self) -> i64 { + self.value.as_i64() + } +} + +/// The value type for a group code (DXF Reference, "Group Code Value Types"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Str, + Real, + Int16, + Int32, + Int64, + Bool, + Hex, +} + +pub fn kind_of(code: i32) -> Kind { + match code { + 0..=9 => Kind::Str, + 10..=59 => Kind::Real, + 60..=79 => Kind::Int16, + 90..=99 => Kind::Int32, + 100..=109 => Kind::Str, + 110..=149 => Kind::Real, + 160..=169 => Kind::Int64, + 170..=179 => Kind::Int16, + 210..=239 => Kind::Real, + 270..=289 => Kind::Int16, + 290..=299 => Kind::Bool, + 300..=309 => Kind::Str, + 310..=319 => Kind::Hex, + 320..=369 => Kind::Str, + 370..=389 => Kind::Int16, + 390..=399 => Kind::Str, + 400..=409 => Kind::Int16, + 410..=419 => Kind::Str, + 420..=429 => Kind::Int32, + 430..=439 => Kind::Str, + 440..=449 => Kind::Int32, + 450..=459 => Kind::Int32, + 460..=469 => Kind::Real, + 470..=479 => Kind::Str, + 480..=481 => Kind::Str, + 999 => Kind::Str, + 1000..=1003 => Kind::Str, + 1004 => Kind::Hex, + 1005..=1009 => Kind::Str, + 1010..=1059 => Kind::Real, + 1060..=1070 => Kind::Int16, + 1071 => Kind::Int32, + _ => Kind::Str, + } +} + +fn typed(code: i32, text: &str) -> Value { + let t = text.trim(); + match kind_of(code) { + Kind::Real => t.parse::().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)), + Kind::Int16 | Kind::Int32 | Kind::Int64 => { + // A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the + // group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as + // the integer spelling reads them); NaN, inf and text become 0. + let (lo, hi) = match kind_of(code) { + Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)), + Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)), + _ => (i64::MIN, i64::MAX), + }; + t.parse::() + .map(Value::Int) + .unwrap_or_else(|_| Value::Int(t.parse::().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi)))) + } + Kind::Bool => Value::Bool(t.parse::().unwrap_or(0) != 0), + Kind::Hex => Value::Hex(normalize_hex(t)), + Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()), + } +} + +const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; +const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF"; +/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this +/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine +/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB +/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to +/// raise or lower it. +pub const MAX_TAGS: usize = 100_000_000; + +/// Environment variable overriding [`MAX_TAGS`] (a positive integer). +pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS"; + +/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else +/// [`MAX_TAGS`]. +pub fn max_tags() -> usize { + max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref()) +} + +fn max_tags_from(v: Option<&str>) -> usize { + v.and_then(|s| s.trim().replace('_', "").parse::().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS) +} + +mod codepage; +pub use codepage::decode_code_page; + +/// Parse a DXF file (ASCII or binary). +pub fn parse(bytes: &[u8]) -> Result> { + parse_with_limit(bytes, max_tags()) +} + +/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from +/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in +/// milliseconds instead of after it has filled memory. +pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { + if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) { + return parse_binary_with_limit(body, max_tags); + } + let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes); + // Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at + // EOF just as the parser does. Blank lines do not count toward the limit. + let mut lines = byte_lines(bytes); + let mut tags = 0; + while let Some(code) = lines.next() { + let code = code.trim_ascii(); + if code.is_empty() { + continue; + } + let Some(value) = lines.next() else { break }; + tags += 1; + if code == b"0" && value.trim_ascii() == b"EOF" { + break; + } + } + if tags > max_tags { + return Err(DxfError::TooLarge { tags, limit: max_tags }); + } + parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags) +} + +/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one. +fn escape_at(chars: &[char], i: usize) -> Option { + if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'U') || chars.get(i + 2) != Some(&'+') { + return None; + } + let hex = chars.get(i + 3..i + 7)?; + if !hex.iter().all(char::is_ascii_hexdigit) { + return None; + } + hex.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?)) +} + +/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a +/// double-byte code page and `XXXX` is the character's two bytes in hex. +pub fn mif_escape_at(chars: &[char], i: usize) -> Option { + if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') { + return None; + } + let enc = codepage::mif_encoding(*chars.get(i + 3)?)?; + let hex = chars.get(i + 4..i + 8)?; + if !hex.iter().all(char::is_ascii_hexdigit) { + return None; + } + let code = hex.iter().try_fold(0u16, |acc, c| Some(acc * 16 + u16::try_from(c.to_digit(16)?).ok()?))?; + let bytes = code.to_be_bytes(); + let text = enc.decode_without_bom_handling_and_without_replacement(&bytes)?; + let mut it = text.chars(); + it.next().filter(|_| it.next().is_none()) +} + +/// Unescape the `\U+XXXX` sequences pre-R2007 DXF uses for characters outside the file's code +/// page (four hex digits, one UTF-16 code unit; characters above U+FFFF are a surrogate pair of +/// escapes), and the `\M+nXXXX` sequences for double-byte East Asian characters. Malformed +/// escapes, unpaired surrogates and unknown `\M+` code pages are kept as literal text. +fn unescape_unicode(s: &str) -> String { + if !s.contains("\\U+") && !s.contains("\\M+") { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let chars: Vec = s.chars().collect(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if let Some(ch) = mif_escape_at(&chars, i) { + out.push(ch); + i += 8; + continue; + } + if let Some(unit) = escape_at(&chars, i) { + if let Some(ch) = char::from_u32(unit) { + out.push(ch); + i += 7; + continue; + } + if (0xD800..0xDC00).contains(&unit) + && let Some(low) = escape_at(&chars, i + 7).filter(|u| (0xDC00..0xE000).contains(u)) + && let Some(ch) = char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00)) + { + out.push(ch); + i += 14; + continue; + } + } + out.push(c); + i += 1; + } + out +} + +/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like +/// [`str::lines`] otherwise (no empty line after a final terminator). +fn ascii_lines(text: &str) -> impl Iterator { + let mut rest = text; + std::iter::from_fn(move || { + if rest.is_empty() { + return None; + } + let end = rest.find(['\r', '\n']).unwrap_or(rest.len()); + let line = rest.get(..end).unwrap_or_default(); + let after = rest.get(end..).unwrap_or_default(); + rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after); + Some(line) + }) +} + +/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser. +fn byte_lines(mut rest: &[u8]) -> impl Iterator { + std::iter::from_fn(move || { + if rest.is_empty() { + return None; + } + let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len()); + let line = &rest[..end]; + let after = &rest[end..]; + rest = match after { + [b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail, + _ => after, + }; + Some(line) + }) +} + +pub fn parse_ascii(text: &str) -> Result> { + parse_ascii_counted(text, max_tags(), 0) +} + +/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first, +/// with an allocation that can fail: where the system refuses that much memory, opening the file +/// is an error instead of the end of the app. Growing the list instead could also briefly take +/// half as much again. +#[allow(clippy::while_let_loop)] +fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result> { + let mut tags = Vec::new(); + tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?; + let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate(); + loop { + let Some((ln, code_line)) = lines.next() else { break }; + let ct = code_line.trim(); + if ct.is_empty() { + continue; + } + let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?; + let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) }; + let v = typed(code, val); + let v = match v { + Value::Str(s) => Value::Str(unescape_unicode(&s)), + o => o, + }; + tags.push(Tag { code, value: v }); + if tags.len() > max_tags { + return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags }); + } + if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") { + break; + } + } + if tags.is_empty() { + return Err(DxfError::NotDxf); + } + Ok(tags) +} + +struct Cur<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Cur<'a> { + fn take(&mut self, n: usize) -> Result<&[u8]> { + let end = self.p.checked_add(n).ok_or(DxfError::Eof)?; + let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?; + self.p = end; + Ok(s) + } + fn u8(&mut self) -> Result { + Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?) + } + fn i16(&mut self) -> Result { + let s = self.take(2)?; + Ok(i16::from_le_bytes([s[0], s[1]])) + } + fn i32(&mut self) -> Result { + let s = self.take(4)?; + Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]])) + } + fn i64(&mut self) -> Result { + let s = self.take(8)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Ok(i64::from_le_bytes(a)) + } + fn f64(&mut self) -> Result { + let s = self.take(8)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Ok(f64::from_le_bytes(a)) + } + /// The bytes of a zero-terminated string (without the zero), decoded by the caller. + fn cstr(&mut self) -> Result<&'a [u8]> { + let start = self.p; + while self.p < self.b.len() && self.b.get(self.p) != Some(&0) { + self.p += 1; + } + let s = self.b.get(start..self.p).unwrap_or(&[]); + if self.p >= self.b.len() { + return Err(DxfError::Eof); + } + self.p += 1; + Ok(s) + } +} + +/// Binary DXF (R13+: 2-byte group codes). +pub fn parse_binary(b: &[u8]) -> Result> { + parse_binary_with_limit(b, max_tags()) +} + +fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result> { + let mut text = codepage::BinaryText::default(); + let tags = parse_binary_pass(b, max_tags, &mut text)?; + if !text.needs_code_page_pass() { + return Ok(tags); + } + // The first pass's tags go before the second pass runs: both held at once would double + // the peak near the tag limit. + drop(tags); + parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass()) +} + +fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result> { + let mut c = Cur { b, p: 0 }; + let mut tags = Vec::new(); + while c.p < b.len() { + let mut code = i32::from(c.i16()?); + if code == 255 { + code = i32::from(c.i16()?); + } + let v = match kind_of(code) { + Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))), + Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)), + Kind::Int16 => Value::Int(i64::from(c.i16()?)), + Kind::Int32 => Value::Int(i64::from(c.i32()?)), + Kind::Int64 => Value::Int(c.i64()?), + Kind::Bool => Value::Bool(c.u8()? != 0), + Kind::Hex => { + let n = usize::from(c.u8()?); + let bytes = c.take(n)?; + let mut s = String::with_capacity(n * 2); + for x in bytes { + let _ = write!(s, "{x:02X}"); + } + Value::Hex(s) + } + }; + let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF"); + tags.push(Tag { code, value: v }); + if tags.len() > max_tags { + return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags }); + } + if eof { + break; + } + } + if tags.is_empty() { + return Err(DxfError::NotDxf); + } + Ok(tags) +} + +/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point). +pub fn format_real(v: f64) -> String { + if !v.is_finite() { + return "0.0".into(); + } + let s = format!("{v}"); + if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") } +} + +/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional). +/// +/// The output is pure ASCII: non-ASCII characters in strings are written as `\U+XXXX` escapes, +/// the pre-R2007 convention, so it is correct whatever `$DWGCODEPAGE` the header declares. +pub fn write_ascii(tags: &[Tag]) -> String { + write_ascii_as(tags, false) +} + +/// Write tags as ASCII DXF with text in UTF-8 (`utf8`, the R2007+ convention: non-ASCII +/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line +/// breaks inside strings become spaces either way. +pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { + let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) }); + // Both forms are UTF-8 (the escaped one is ASCII). + String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + +/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a +/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions: +/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A +/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page) +/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be +/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII +/// DXF that is valid UTF-8 as UTF-8. +pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { + let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page))); + if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() { + return ascii(tags, Strings::Escaped(None)); + } + bytes +} + +/// How a writer stores string values. +#[derive(Clone, Copy)] +enum Strings { + /// As they are (R2007+). + Utf8, + /// ASCII as it is, other characters in this code page when it has them, otherwise as + /// `\U+XXXX` escapes (before R2007). + Escaped(Option), +} + +fn ascii(tags: &[Tag], strings: Strings) -> Vec { + let mut out = Vec::with_capacity(tags.len() * 16); + for t in tags { + let _ = write!(out, "{:>3}\r\n", t.code); + match &t.value { + Value::Str(s) => push_string(s, strings, &mut out), + Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()), + Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()), + Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()), + Value::Hex(h) => { + // The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes); + // longer data continues in further groups of the same code. Only hex digits are + // written, so a hostile value can't inject tags. + let digits = normalize_hex(h); + let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect(); + if chunks.is_empty() { + chunks.push(""); + } + for (n, chunk) in chunks.into_iter().enumerate() { + if n > 0 { + let _ = write!(out, "\r\n{:>3}\r\n", t.code); + } + out.extend_from_slice(chunk.as_bytes()); + } + } + } + out.extend_from_slice(b"\r\n"); + } + out +} + +/// 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 +/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into +/// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as +/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text +/// is stored as a number; a code outside the 16-bit range is skipped. +pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { + binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) }) +} + +/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as +/// [`write_ascii_in`] does. +pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec { + binary(tags, Strings::Escaped(codepage::encoder(code_page))) +} + +fn binary(tags: &[Tag], strings: Strings) -> Vec { + let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12); + out.extend_from_slice(BINARY_SENTINEL); + for t in tags { + let Ok(code) = i16::try_from(t.code) else { continue }; + let code_bytes = code.to_le_bytes(); + match kind_of(t.code) { + Kind::Str => { + out.extend_from_slice(&code_bytes); + let s = match &t.value { + Value::Str(s) => s.clone(), + v => v.as_str(), + }; + let mut text = Vec::with_capacity(s.len()); + push_string(&s, strings, &mut text); + out.extend(text.into_iter().filter(|&b| b != 0)); + out.push(0); + } + Kind::Real => { + out.extend_from_slice(&code_bytes); + out.extend_from_slice(&t.value.as_f64().to_le_bytes()); + } + Kind::Int16 => { + out.extend_from_slice(&code_bytes); + out.extend_from_slice(&wrap_i16(t.value.as_i64())); + } + Kind::Int32 => { + out.extend_from_slice(&code_bytes); + out.extend_from_slice(&wrap_i32(t.value.as_i64())); + } + Kind::Int64 => { + out.extend_from_slice(&code_bytes); + out.extend_from_slice(&t.value.as_i64().to_le_bytes()); + } + Kind::Bool => { + out.extend_from_slice(&code_bytes); + out.push(u8::from(t.value.as_i64() != 0)); + } + Kind::Hex => { + let bytes = hex_bytes(&t.value.as_str()); + // An empty chunk is still one group, as in ASCII. + let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect(); + if chunks.is_empty() { + chunks.push(&[]); + } + for chunk in chunks { + out.extend_from_slice(&code_bytes); + out.push(u8::try_from(chunk.len()).unwrap_or(u8::MAX)); + out.extend_from_slice(chunk); + } + } + } + } + out +} + +/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with +/// `0` (so the bytes before it are never shifted). Used by the reader and both writers. +fn normalize_hex(s: &str) -> String { + let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect(); + if out.len() % 2 == 1 { + out.push('0'); + } + out +} + +/// The bytes of hex text, two digits each (see [`normalize_hex`]). +fn hex_bytes(s: &str) -> Vec { + let digits: Vec = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect(); + digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect() +} + +/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the +/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps. +fn wrap_i16(v: i64) -> [u8; 2] { + match u16::try_from(v) { + Ok(u) => u.to_le_bytes(), + Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(), + } +} + +fn wrap_i32(v: i64) -> [u8; 4] { + match u32::try_from(v) { + Ok(u) => u.to_le_bytes(), + Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(), + } +} + +/// Whether `bytes` are a binary DXF file (they start with the binary sentinel). +pub fn is_binary(bytes: &[u8]) -> bool { + bytes.starts_with(BINARY_SENTINEL) +} + +/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is +/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes +/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without +/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash +/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back +/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that +/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for +/// escapes, but a reader that looks first would take that byte and the `U+` after it for one. +fn push_string(s: &str, strings: Strings, out: &mut Vec) { + let chars: Vec = s.chars().collect(); + let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+'); + for (i, &c) in chars.iter().enumerate() { + match (c, strings) { + ('\r' | '\n', _) => out.push(b' '), + ('\\', _) if escape_at(i + 1) => { + out.extend_from_slice(b"\\U+005C"); + } + (c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), + (c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()), + (c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {} + (c, Strings::Escaped(_)) => { + let mut units = [0u16; 2]; + for u in c.encode_utf16(&mut units) { + let _ = write!(out, "\\U+{u:04X}"); + } + } + } + } +} + +/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte +/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`]. +fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec) -> bool { + let start = out.len(); + if !codepage::encode_char(c, d, out) { + return false; + } + if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') { + out.truncate(start); + return false; + } + true +} + +/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`. +#[derive(Clone, Debug, Default)] +pub struct Section { + pub name: String, + pub tags: Vec, +} + +/// Split a tag stream into sections. +pub fn sections(tags: &[Tag]) -> Vec
{ + let mut out = Vec::new(); + let mut i = 0; + while i < tags.len() { + let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION"); + if is_sec { + let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default(); + let start = i + 2; + let mut j = start; + while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) { + j += 1; + } + out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() }); + i = j + 1; + } else { + i += 1; + } + } + out +} + +/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0). +pub fn records(tags: &[Tag]) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for t in tags { + if t.code == 0 { + out.push((t.str(), Vec::new())); + } else if let Some(last) = out.last_mut() { + last.1.push(t.clone()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literal_escape_text_roundtrips() { + for utf8 in [false, true] { + for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] { + let tags = vec![Tag::s(8, text)]; + let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap(); + assert_eq!(back, tags, "utf8={utf8} {text}"); + let back = parse_binary(&write_binary(&tags, utf8)).unwrap(); + assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}"); + } + } + } + + #[test] + fn blank_lines_do_not_consume_group_code_limit() { + let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one"); + assert_eq!(tags, vec![Tag::s(0, "EOF")]); + } + + #[test] + fn ascii_roundtrip() { + let tags = vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + Tag::s(0, "LINE"), + Tag::s(8, "0"), + Tag::f(10, 1.5), + Tag::f(20, 2.0), + Tag::i(62, 1), + Tag::s(0, "ENDSEC"), + Tag::s(0, "EOF"), + ]; + let text = write_ascii(&tags); + let back = parse(text.as_bytes()).unwrap(); + assert_eq!(back, tags); + let secs = sections(&back); + assert_eq!(secs.len(), 1); + assert_eq!(secs[0].name, "ENTITIES"); + let recs = records(&secs[0].tags); + assert_eq!(recs[0].0, "LINE"); + assert_eq!(recs[0].1.len(), 4); + } + + #[test] + fn binary_parse() { + let mut b = BINARY_SENTINEL.to_vec(); + let push_str = |b: &mut Vec, code: i16, s: &str| { + b.extend_from_slice(&code.to_le_bytes()); + b.extend_from_slice(s.as_bytes()); + b.push(0); + }; + push_str(&mut b, 0, "LINE"); + b.extend_from_slice(&10i16.to_le_bytes()); + b.extend_from_slice(&3.25f64.to_le_bytes()); + b.extend_from_slice(&62i16.to_le_bytes()); + b.extend_from_slice(&5i16.to_le_bytes()); + push_str(&mut b, 0, "EOF"); + let tags = parse(&b).unwrap(); + assert_eq!(tags[1], Tag::f(10, 3.25)); + assert_eq!(tags[2], Tag::i(62, 5)); + } + + #[test] + fn malformed_inputs() { + assert!(parse(b"").is_err()); + assert!(parse(b"abc\nxyz\n").is_err()); + assert!(parse(b"0\n").is_err()); + let mut b = BINARY_SENTINEL.to_vec(); + b.extend_from_slice(&10i16.to_le_bytes()); + b.extend_from_slice(&[1, 2, 3]); + assert!(parse(&b).is_err()); + // Bad numbers become 0 rather than failing. + let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap(); + assert_eq!(t[0].f64(), 0.0); + } + + #[test] + fn unicode_escapes_and_latin1() { + let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap(); + assert_eq!(t[0].str(), "Café"); + let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap(); + assert_eq!(t[0].str(), "Café"); + } + + #[test] + fn surrogate_pair_escapes() { + let t = parse(b"1\n\\U+D83D\\U+DE00 \\u+56fe\\U+53f7\n0\nEOF\n").unwrap(); + assert_eq!(t[0].str(), "😀 \\u+56fe号"); + } + + #[test] + fn hostile_escapes_stay_literal() { + for s in [ + "\\U+", + "\\U", + "\\", + "a\\U+ZZZZ", + "\\U+12", + "\\U+D800", + "\\U+DC00", + "\\U+D800\\U+0041", + "\\U+D83D\\U+DE", + "\\U++123", + "\\U+-123", + "x\\U+12\u{e9}4", + ] { + let t = parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap(); + let expect = if s == "\\U+D800\\U+0041" { "\\U+D800A" } else { s }; + assert_eq!(t[0].str(), expect, "{s:?}"); + } + } + + #[test] + fn writes_non_ascii_as_escapes() { + let tags = vec![Tag::s(1, "图号 İø 😀\r\nend"), Tag::s(1000, "Ø"), Tag::s(0, "EOF")]; + let text = write_ascii(&tags); + assert!(text.is_ascii()); + assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+D83D\\U+DE00 end\r\n")); + let back = parse(text.as_bytes()).unwrap(); + assert_eq!(back[0].str(), "图号 İø 😀 end"); + assert_eq!(back[1].str(), "Ø"); + } + + #[test] + fn binary_strings_unescape() { + let mut b = BINARY_SENTINEL.to_vec(); + b.extend_from_slice(&1i16.to_le_bytes()); + b.extend_from_slice(b"\\U+56FE\\U+53F7\0"); + b.extend_from_slice(&0i16.to_le_bytes()); + b.extend_from_slice(b"EOF\0"); + assert_eq!(parse(&b).unwrap()[0].str(), "图号"); + } +} + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn tag_limit_defaults_and_env_override() { + assert_eq!(max_tags_from(None), MAX_TAGS); + assert_eq!(max_tags_from(Some("")), MAX_TAGS); + assert_eq!(max_tags_from(Some("0")), MAX_TAGS); + assert_eq!(max_tags_from(Some("abc")), MAX_TAGS); + assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000); + // Issue #1948: the default must admit a DXF rendition well above 50 million group codes. + const { assert!(MAX_TAGS >= 100_000_000) }; + } + + #[test] + fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() { + let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap(); + assert_eq!((tags.len(), tags.capacity()), (3, 3)); + let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err(); + assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}"); + } +} diff --git a/crates/dxf/src/version.rs b/crates/dxf/src/version.rs new file mode 100644 index 00000000..3d0b96c1 --- /dev/null +++ b/crates/dxf/src/version.rs @@ -0,0 +1,117 @@ +//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`). + +/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format +/// also serves the releases after it (2018 is still AutoCAD's current format). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Version { + /// AutoCAD 2000/2002 (AC1015). + R2000, + /// AutoCAD 2004–2006 (AC1018). + R2004, + /// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8. + R2007, + /// AutoCAD 2010–2012 (AC1024). + R2010, + /// AutoCAD 2013–2017 (AC1027). + R2013, + /// AutoCAD 2018 and later (AC1032), the default. + #[default] + R2018, +} + +impl Version { + /// Every version, oldest first. + pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018]; + + /// The `$ACADVER` code ("AC1032"). + pub fn code(self) -> &'static str { + match self { + Version::R2000 => "AC1015", + Version::R2004 => "AC1018", + Version::R2007 => "AC1021", + Version::R2010 => "AC1024", + Version::R2013 => "AC1027", + Version::R2018 => "AC1032", + } + } + + /// The release year that names the version ("2018"). + pub fn year(self) -> &'static str { + match self { + Version::R2000 => "2000", + Version::R2004 => "2004", + Version::R2007 => "2007", + Version::R2010 => "2010", + Version::R2013 => "2013", + Version::R2018 => "2018", + } + } + + /// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let year = s.strip_prefix(['R', 'r']).unwrap_or(s); + Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s)) + } + + /// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes + /// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when + /// the code isn't a version code at all. + pub fn for_acadver(code: &str) -> Option { + let code = code.trim(); + if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) { + return Some(v); + } + code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?; + let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::().ok())?; + Some(match n { + ..1018 => Version::R2000, + 1018..1021 => Version::R2004, + 1021..1024 => Version::R2007, + 1024..1027 => Version::R2010, + 1027..1032 => Version::R2013, + _ => Version::R2018, + }) + } + + /// 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`. + pub fn utf8(self) -> bool { + self >= Version::R2007 + } +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.year()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_years_and_codes() { + assert_eq!(Version::parse("2018"), Some(Version::R2018)); + assert_eq!(Version::parse(" r2004 "), Some(Version::R2004)); + assert_eq!(Version::parse("ac1021"), Some(Version::R2007)); + assert_eq!(Version::parse("2005"), None); + assert_eq!(Version::parse(""), None); + for v in Version::ALL { + assert_eq!(Version::parse(v.code()), Some(v)); + assert_eq!(Version::parse(v.year()), Some(v)); + } + } + + #[test] + fn file_versions_map_to_the_nearest_writable_one() { + assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000)); + assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000)); + assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010)); + assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018)); + assert_eq!(Version::for_acadver("MC0.0"), None); + assert_eq!(Version::for_acadver("AC"), None); + assert_eq!(Version::for_acadver("AC99999999999999999999"), None); + } +} diff --git a/crates/dxf/tests/ascii_bom.rs b/crates/dxf/tests/ascii_bom.rs new file mode 100644 index 00000000..9d80aff1 --- /dev/null +++ b/crates/dxf/tests/ascii_bom.rs @@ -0,0 +1,19 @@ +//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped. + +const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + +#[test] +fn utf8_bom_bytes_are_skipped() { + let plain = craft_dxf::parse(BODY.as_bytes()).expect("plain parses"); + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(BODY.as_bytes()); + let with_bom = craft_dxf::parse(&bytes); + assert!(with_bom.is_ok(), "{with_bom:?}"); + assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len()); +} + +#[test] +fn bom_text_is_skipped() { + let text = format!("\u{feff}{BODY}"); + assert!(craft_dxf::parse_ascii(&text).is_ok()); +} diff --git a/crates/dxf/tests/binary_write.rs b/crates/dxf/tests/binary_write.rs new file mode 100644 index 00000000..fb2d0821 --- /dev/null +++ b/crates/dxf/tests/binary_write.rs @@ -0,0 +1,75 @@ +//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`, +//! and hostile binary input never panics. + +use craft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary}; + +fn sample() -> Vec { + vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + Tag::s(0, "TEXT"), + Tag::s(5, "1F"), + Tag::f(10, -3.25), + Tag::i(62, 5), + Tag::i(90, 70_000), + Tag::i(160, 1 << 40), + Tag::new(290, Value::Bool(true)), + Tag::new(310, Value::Hex("00FFA5".into())), + Tag::s(1, "图号 Café 😀"), + Tag::i(1071, -2), + Tag::s(0, "ENDSEC"), + Tag::s(0, "EOF"), + ] +} + +#[test] +fn binary_roundtrips_every_value_kind() { + for utf8 in [false, true] { + let bytes = write_binary(&sample(), utf8); + assert!(is_binary(&bytes)); + assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}"); + } + // Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8. + let old = write_binary(&sample(), false); + assert!(old.windows(7).any(|w| w == b"\\U+56FE")); + let new = write_binary(&sample(), true); + assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes())); +} + +#[test] +fn binary_converts_values_to_their_codes_kind() { + // A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk. + let long = "AB".repeat(300); + let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]; + let back = parse(&write_binary(&tags, true)).unwrap(); + assert_eq!(back[0], Tag::i(70, 12)); + assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX))); + assert_eq!(back[2].code, 310); + assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255"); + assert_eq!(back[2].str().len() + back[3].str().len(), 600); +} + +#[test] +fn utf8_ascii_keeps_text_and_drops_line_breaks() { + let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true); + assert!(text.contains("图号 Ä\r\n")); + assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä"); +} + +#[test] +fn hostile_binary_input_never_panics() { + let good = write_binary(&sample(), true); + // Every truncation, and every single-byte corruption of the body. + for n in 0..good.len() { + let _ = parse(good.get(..n).unwrap()); + } + for i in 22..good.len() { + for b in [0u8, 0x7f, 0xff] { + let mut bad = good.clone(); + bad[i] = b; + let _ = parse(&bad); + } + } + // A sentinel and nothing else is not a drawing. + assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err()); +} diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs new file mode 100644 index 00000000..647d8115 --- /dev/null +++ b/crates/dxf/tests/code_page_write.rs @@ -0,0 +1,116 @@ +//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their +//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text. + +use craft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in}; + +fn file(code_page: &str, text: &str) -> Vec { + 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"), + ] +} + +fn contains(bytes: &[u8], part: &[u8]) -> bool { + bytes.windows(part.len()).any(|w| w == part) +} + +#[test] +fn text_is_written_in_the_code_page() { + // (code page, text, the bytes written for it) + let cases: [(&str, &str, &[u8]); 6] = [ + ("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"), + ("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"), + ("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"), + ("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"), + ("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"), + // A code page CADCraft doesn't know: everything non-ASCII is escaped. + ("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"), + ]; + for (cp, text, raw) in cases { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} + +/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what +/// `write_ascii` writes (pure ASCII with escapes), line breaks included. +#[test] +fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() { + for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] { + let tags = file(cp, "Ação\r\nfim"); + let bytes = write_ascii_in(&tags, cp); + assert!(bytes.is_ascii(), "{cp}"); + assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}"); + assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}"); + assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary"); + } +} + +/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472 +/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with +/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes. +#[test] +fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() { + let tags = file("ANSI_1251", "СІ"); + let ascii = write_ascii_in(&tags, "ANSI_1251"); + assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii)); + assert!(contains(&ascii, b"\\U+0421\\U+0406")); + assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ"); + let binary = write_binary_in(&tags, "ANSI_1251"); + assert!(contains(&binary, b"\xD1\xB2")); + assert_eq!(parse(&binary).unwrap()[10].str(), "СІ"); + // Bytes that are not valid UTF-8 stay as they are. + assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9")); +} + +/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes, +/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes +/// would read as UTF-8. +#[test] +fn literal_escapes_survive_code_page_text() { + for (cp, text) in + [("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")] + { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} + +/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ` +/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is +/// written as an escape, so even a reader that looks for escapes before it decodes the code page +/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written. +#[test] +fn a_trail_byte_backslash_never_starts_an_escape() { + let cases: [(&str, &str, &[u8], &[u8]); 4] = [ + ("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"), + ("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"), + ("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"), + ("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"), + ]; + for (cp, text, written, never) in cases { + let tags = file(cp, text); + for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] { + assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes)); + assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape"); + assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}"); + } + } +} diff --git a/crates/dxf/tests/conformance.rs b/crates/dxf/tests/conformance.rs new file mode 100644 index 00000000..1eaec8fb --- /dev/null +++ b/crates/dxf/tests/conformance.rs @@ -0,0 +1,145 @@ +//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures. +//! Handwritten examples, not files from a CAD application or generated by our writer. +//! Background on the tag model and file structure: +//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html +//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html +//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes. + +use craft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary}; + +const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; + +fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> { + vec![ + (Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"), + (Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"), + (Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"), + (Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"), + (Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"), + (Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"), + (Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"), + (Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"), + (Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"), + (Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"), + (Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"), + (Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"), + (Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"), + ] +} + +#[test] +fn reads_independently_authored_ascii_and_binary_values() { + for (expected, ascii, binary) in fixtures() { + assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code); + let bytes = [SENTINEL, binary].concat(); + assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code); + } +} + +#[test] +fn writes_independently_specified_ascii_and_binary_bytes() { + for (tag, ascii, binary) in fixtures() { + for utf8 in [false, true] { + let tags = std::slice::from_ref(&tag); + assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code); + assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code); + } + } +} + +#[test] +fn integer_code_ranges_have_independent_binary_widths() { + // Specification ranges and widths, never kind_of(): a shared reader/writer mistake + // can round-trip every boundary value while still using the wrong number of bytes. + let widths = [ + (vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)), + (vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)), + (std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX), + ]; + for (ranges, width, min, max) in widths { + for code in ranges.into_iter().flatten() { + for value in [min, min + 1, -1, 0, 1, max - 1, max] { + // Binary DXF uses a little-endian 16-bit group code and a fixed-width, + // little-endian two's-complement integer. Encoding is version-independent. + let code_bytes = u16::try_from(code).unwrap().to_le_bytes(); + let value_bytes = value.to_le_bytes(); + let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat(); + let tags = [Tag::i(code, value)]; + for utf8 in [false, true] { + assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}"); + } + assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}"); + } + } + } +} + +#[test] +fn text_encoding_has_independent_wire_expectations() { + let tags = [Tag::s(1, "é😀")]; + let escaped = b"\\U+00E9\\U+D83D\\U+DE00"; + let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80"; + for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] { + let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat(); + let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat(); + assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}"); + assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}"); + assert_eq!(parse(&ascii).unwrap(), tags); + assert_eq!(parse(&binary).unwrap(), tags); + } + // Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes. + let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n"; + let binary = [ + SENTINEL, + b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0", + ] + .concat(); + for bytes in [ascii.as_slice(), binary.as_slice()] { + let tags = parse(bytes).unwrap(); + assert_eq!(tags[7], Tag::s(1, "€é")); + } +} + +#[test] +fn ascii_accepts_line_endings_padding_and_no_final_newline() { + let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")]; + for newline in ["\n", "\r\n"] { + for final_newline in [false, true] { + let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline); + if final_newline { + text.push_str(newline); + } + assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}"); + } + } +} + +#[test] +fn malformed_and_truncated_values_are_errors_not_panics() { + assert!(matches!(parse(b""), Err(DxfError::NotDxf))); + assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. }))); + assert!(matches!(parse(b"10\n"), Err(DxfError::Eof))); + assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf))); + for (_, _, binary) in fixtures() { + // Every cut inside the first record is an error, unlike cuts between complete records: + // the tag API deliberately accepts streams without an EOF record. + for cut in 1..binary.len() { + let bytes = [SENTINEL, &binary[..cut]].concat(); + assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}"); + } + } +} + +#[test] +fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() { + let ascii = b"1\nA\n70\n-7\n0\nEOF\n"; + let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat(); + for bytes in [ascii.as_slice(), binary.as_slice()] { + for limit in 0..3 { + assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit)); + } + for limit in [3, 4] { + assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]); + } + } +} diff --git a/crates/dxf/tests/hardening.rs b/crates/dxf/tests/hardening.rs new file mode 100644 index 00000000..4d013f1f --- /dev/null +++ b/crates/dxf/tests/hardening.rs @@ -0,0 +1,220 @@ +//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding. + +use craft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary}; + +const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; + +fn bin_with(f: impl FnOnce(&mut Vec)) -> Vec { + let mut b = SENTINEL.to_vec(); + f(&mut b); + b.extend_from_slice(&0i16.to_le_bytes()); + b.extend_from_slice(b"EOF\0"); + b +} + +#[test] +fn binary_reals_that_are_not_finite_become_zero() { + for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let b = bin_with(|b| { + b.extend_from_slice(&10i16.to_le_bytes()); + b.extend_from_slice(&bits.to_le_bytes()); + }); + let tags = parse(&b).unwrap(); + assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}"); + } +} + +#[test] +fn ascii_accepts_bom_and_cr_only_line_ends() { + let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]; + let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r"; + assert_eq!(parse(text.as_bytes()).unwrap(), want); + let mut bom = b"\xEF\xBB\xBF".to_vec(); + bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes()); + assert_eq!(parse(&bom).unwrap(), want); + // Mixed terminators. + assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want); +} + +#[test] +fn cr_only_files_count_against_the_tag_limit() { + let mut text = String::new(); + for _ in 0..50 { + text.push_str("999\rcomment\r"); + } + text.push_str("0\rEOF\r"); + assert!(parse_with_limit(text.as_bytes(), 10).is_err()); + assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51); +} + +#[test] +fn one_bad_byte_spoils_one_line_not_the_file() { + // Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte. + let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec(); + b.extend_from_slice("Größe".as_bytes()); + b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n"); + let t = parse(&b).unwrap(); + assert_eq!(t[1].str(), "ANSI_1251"); + assert_eq!(t[2].str(), "Größe"); + assert_eq!(t[3].str(), "Привет"); +} + +#[test] +fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() { + // ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII + // reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string + // from the code page, because a pre-R2007 binary file has no way to escape to UTF-8. + let utf8 = "Größe".as_bytes(); + let latin = b"Caf\xE9"; + let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec(); + a.extend_from_slice(utf8); + a.extend_from_slice(b"\n1\n"); + a.extend_from_slice(latin); + a.extend_from_slice(b"\n0\nEOF\n"); + let from_ascii = parse(&a).unwrap(); + let b = bin_with(|b| { + for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] { + b.extend_from_slice(&code.to_le_bytes()); + b.extend_from_slice(s); + b.push(0); + } + }); + let from_binary = parse(&b).unwrap(); + assert_eq!(from_ascii[2].str(), "Größe"); + assert_eq!(from_ascii[3].str(), "Café"); + assert_eq!(from_binary[2].str(), "Größe"); + assert_eq!(from_binary[3].str(), "Café"); +} + +#[test] +fn trailing_spaces_keep_sections_and_eof() { + let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap(); + // The EOF with a trailing space still ends the file. + assert_eq!(t.len(), 5); + let s = sections(&t); + assert_eq!(s.len(), 1); + assert_eq!(s[0].name, "ENTITIES"); + assert_eq!(s[0].tags.len(), 1); + // Binary EOF too. + let mut b = SENTINEL.to_vec(); + b.extend_from_slice(&0i16.to_le_bytes()); + b.extend_from_slice(b"EOF \0"); + b.extend_from_slice(&1i16.to_le_bytes()); + b.extend_from_slice(b"after\0"); + assert_eq!(parse(&b).unwrap().len(), 1); +} + +#[test] +fn integer_groups_reject_non_finite_and_clamp() { + let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap(); + assert_eq!(t[0], Tag::i(70, 0)); + assert_eq!(t[1], Tag::i(70, 0)); + // Clamped to what the group's width holds, unsigned values included (the binary writer + // keeps up to 65535 and 4294967295 as their bit patterns). + assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX))); + assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN))); + assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX))); + assert_eq!(t[5], Tag::i(160, i64::MAX)); + assert_eq!(t[6], Tag::i(160, 0)); + assert_eq!(t[7], Tag::i(62, 12)); + assert_eq!(Value::Str("inf".into()).as_i64(), 0); +} + +/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review): +/// `65535.0` is 65535 like `65535`, not 32767. +#[test] +fn unsigned_values_read_the_same_in_either_spelling() { + let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap(); + assert_eq!(t[0], t[1]); + assert_eq!(t[0], Tag::i(70, 65535)); + assert_eq!(t[2], t[3]); + assert_eq!(t[2], Tag::i(90, 4_294_967_295)); +} + +#[test] +fn hex_groups_pad_odd_digits_and_drop_junk() { + let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap(); + assert_eq!(t[0].value, Value::Hex("ABC0".into())); + assert_eq!(t[1].value, Value::Hex("ABC1".into())); + assert_eq!(t[2].value, Value::Hex(String::new())); + assert_eq!(t[3].value, Value::Hex("0aF0".into())); + // The binary writer agrees with the reader: the odd digit is padded, nothing shifts. + let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap(); + assert_eq!(back[0].value, Value::Hex("ABC0".into())); +} + +#[test] +fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() { + let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into())); + let text = write_ascii(&[evil, Tag::s(0, "EOF")]); + let back = parse(text.as_bytes()).unwrap(); + assert_eq!(back.len(), 2, "{text:?}"); + assert_eq!(back[0].value, Value::Hex("AB0EF0".into())); + let long = "AB".repeat(300); + let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap(); + let sizes: Vec = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect(); + assert_eq!(sizes, vec![254, 254, 92]); + // An empty chunk is still one group. + let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap(); + assert_eq!(back[0].code, 310); +} + +#[test] +fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() { + let tags = [ + Tag::i(70, 65535), + Tag::i(70, 40000), + Tag::i(70, 70000), + Tag::i(70, -40000), + Tag::i(90, 0xFFFF_FFFF), + Tag::i(90, 1 << 40), + Tag::i(90, -(1 << 40)), + Tag::s(0, "EOF"), + ]; + let back = parse(&write_binary(&tags, true)).unwrap(); + assert_eq!(back[0], Tag::i(70, -1)); + assert_eq!(back[1], Tag::i(70, 40000 - 65536)); + assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX))); + assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN))); + assert_eq!(back[4], Tag::i(90, -1)); + assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX))); + assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN))); +} + +/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file. +fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec; 2] { + let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes(); + for s in strings { + a.extend_from_slice(b"1\n"); + a.extend_from_slice(s); + a.push(b'\n'); + } + a.extend_from_slice(b"0\nEOF\n"); + let b = bin_with(|b| { + for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) { + b.extend_from_slice(&code.to_le_bytes()); + b.extend_from_slice(s); + b.push(0); + } + }); + [parse(&a).unwrap(), parse(&b).unwrap()] +} + +#[test] +fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() { + // C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second + // string is plainly Shift-JIS, so the file is not UTF-8. + for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) { + assert_eq!(t[2].str(), "\u{FF83}\u{FF71}"); + assert_eq!(t[3].str(), "図面"); + } +} + +#[test] +fn east_asian_file_that_is_all_utf8_reads_as_utf8() { + for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) { + assert_eq!(t[2].str(), "図面"); + assert_eq!(t[3].str(), "ñ"); + assert_eq!(t[4].str(), "plain"); + } +} diff --git a/crates/dxf/tests/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs new file mode 100644 index 00000000..0be58f1a --- /dev/null +++ b/crates/dxf/tests/hex_chunks.rs @@ -0,0 +1,119 @@ +//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and +//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255. +//! Inspect writer bytes without our parser; accept any compliant partition preserving payload, +//! code and following-record framing. Oversized binary read fixtures test tolerance only. +//! Pending writer regressions extend the chunk-size finding in issue #1025. +//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files: +//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm +//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm + +use craft_dxf::{Tag, Value, parse, write_ascii_as, write_binary}; + +const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; +const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7 + +fn payload(length: usize) -> Vec { + (0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() +} + +fn input(code: i32, bytes: &[u8]) -> [Tag; 2] { + [Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)] +} + +fn check_ascii(code: i32, bytes: &[u8], utf8: bool) { + let wire = write_ascii_as(&input(code, bytes), utf8); + let lines: Vec<_> = wire.lines().collect(); + let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap()); + assert_eq!(following[0].trim(), "70"); + assert_eq!(following[1], "-7"); + assert!(!chunks.is_empty(), "an empty payload must still have a tag"); + let (records, remainder) = chunks.as_chunks::<2>(); + assert!(remainder.is_empty(), "incomplete group/value pair"); + let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len()); + let mut back = Vec::new(); + for record in records { + assert_eq!(record[0].trim().parse::().unwrap(), code, "{context}"); + let text = record[1]; + assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len()); + assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex"); + let (pairs, remainder) = text.as_bytes().as_chunks::<2>(); + assert!(remainder.is_empty(), "{context}: partial byte"); + back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())); + } + assert_eq!(back, bytes, "{context}: complete payload and byte order"); +} + +fn check_binary(code: i32, bytes: &[u8], utf8: bool) { + let wire = write_binary(&input(code, bytes), utf8); + let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap(); + assert!(!chunks.is_empty(), "an empty payload must still have a tag"); + let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len()); + let mut back = Vec::new(); + while !chunks.is_empty() { + let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap(); + assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}"); + assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit"); + let (chunk, next) = rest.split_at(usize::from(length)); + back.extend_from_slice(chunk); + chunks = next; + } + assert_eq!(back, bytes, "{context}: complete payload and byte order"); +} + +#[test] +fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() { + for code in (310..=319).chain([1004]) { + for length in [0, 1, 2, 126, 127] { + for utf8 in [false, true] { + let bytes = payload(length); + check_ascii(code, &bytes, utf8); + check_binary(code, &bytes, utf8); + } + } + } +} + +#[test] +fn binary_reader_tolerates_the_full_length_byte_domain() { + // This is deliberately permissive input, not an assertion that oversized chunks conform + // or that the writer should produce them. The following record catches framing mistakes. + for code in (310_u16..=319).chain([1004]) { + for length in 0..=u8::MAX { + let bytes = payload(usize::from(length)); + let mut wire = SENTINEL.to_vec(); + wire.extend_from_slice(&code.to_le_bytes()); + wire.push(length); + wire.extend_from_slice(&bytes); + wire.extend_from_slice(FOLLOWING_BINARY); + assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}"); + } + } +} + +#[test] +#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"] +fn ascii_writer_splits_large_payloads_into_conforming_chunks() { + for code in (310..=319).chain([1004]) { + for length in [128, 254, 255, 256, 1024] { + for utf8 in [false, true] { + check_ascii(code, &payload(length), utf8); + } + } + } +} + +#[test] +#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"] +fn binary_writer_splits_large_payloads_into_conforming_chunks() { + for code in (310..=319).chain([1004]) { + for length in [128, 254, 255, 256, 1024] { + for utf8 in [false, true] { + check_binary(code, &payload(length), utf8); + } + } + } +} diff --git a/crates/dxf/tests/roundtrip.rs b/crates/dxf/tests/roundtrip.rs new file mode 100644 index 00000000..1d9dbb91 --- /dev/null +++ b/crates/dxf/tests/roundtrip.rs @@ -0,0 +1,148 @@ +//! Deterministic tag preservation through the public byte API, in both encodings and every +//! supported version. Inputs have the group code's type and representable values; lossy +//! coercions are not accepted as round-trip equality. Real values must retain their bits. + +use craft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary}; + +fn check(case: &str, body: &[Tag]) { + for version in Version::ALL { + let mut original = vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "HEADER"), + Tag::s(9, "$ACADVER"), + Tag::s(1, version.code()), + Tag::s(9, "$DWGCODEPAGE"), + Tag::s(3, "ANSI_1252"), + Tag::s(0, "ENDSEC"), + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + ]; + original.extend_from_slice(body); + original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]); + for binary in [false, true] { + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · {version} · binary={binary} · pass={pass}"); + let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() }; + assert_eq!(is_binary(&bytes), binary, "{context}"); + let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}")); + assert_eq!(back.len(), original.len(), "{context}: tag count"); + for (index, (expected, actual)) in original.iter().zip(&back).enumerate() { + assert_eq!(actual, expected, "{context}: tag[{index}]"); + if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) { + assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits"); + } + } + assert_eq!(back, current, "{context}: no additional changes on resave"); + current = back; + } + } + } +} + +#[test] +fn integer_width_boundaries() { + // Explicit reference ranges, not obtained from the codec's kind_of(). + let widths = [ + (vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)), + (vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)), + (std::iter::once(160..=169).collect(), i64::MIN, i64::MAX), + ]; + for (ranges, min, max) in widths { + for value in [min, min + 1, -1, 0, 1, max - 1, max] { + let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect(); + check(&format!("integer/{min}..{max}/{value}"), &tags); + } + } +} + +#[test] +fn finite_reals_keep_all_bits() { + let values = [ + 0.0, + -0.0, + f64::from_bits(1), + -f64::from_bits(1), + f64::MIN_POSITIVE, + f64::MIN_POSITIVE.next_down(), + 0.1, + -3.25, + 1.0_f64.next_down(), + 1.0, + 1.0_f64.next_up(), + 1e-100, + -1e100, + f64::MAX, + f64::MIN, + ]; + for value in values { + let tags: Vec<_> = + [10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect(); + check(&format!("real/{:016X}", value.to_bits()), &tags); + } +} + +#[test] +fn booleans() { + for value in [false, true] { + let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect(); + check(&format!("bool/{value}"), &tags); + } +} + +#[test] +fn strings_preserve_whitespace_unicode_and_literal_escapes() { + // No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads. + let values = [ + ("empty", ""), + ("spaces", " leading and trailing "), + ("tabs", "\tindented\t"), + ("unicode", "Café Ø 图号 Україна 😀 e\u{301}"), + ("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"), + ("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"), + ]; + for (case, value) in values { + let tags: Vec<_> = + [0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005] + .into_iter() + .flatten() + .map(|code| Tag::s(code, value)) + .collect(); + check(case, &tags); + } + for length in [1, 254, 255, 256, 1024] { + check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]); + } +} + +#[test] +fn hex_chunks_keep_bytes_and_order() { + // DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length + // byte can represent more, but that is reader tolerance, not writer conformance. + // Larger payloads need chunk-size/reassembly checks rather than exact tag equality; + // see hex_chunks.rs for the independent wire checks and pending writer regressions. + for length in [0, 1, 2, 126, 127] { + let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect(); + let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect(); + check(&format!("hex/bytes={length}"), &tags); + } +} + +#[test] +fn mixed_values_and_repeated_codes_keep_order() { + check( + "mixed/repeated-codes", + &[ + Tag::s(0, "TEST"), + Tag::f(10, 1.25), + Tag::s(1, "first"), + Tag::f(10, -2.5), + Tag::i(70, -7), + Tag::s(1, "second"), + Tag::new(290, Value::Bool(true)), + Tag::new(310, Value::Hex("00FF80".into())), + Tag::i(160, i64::MAX), + Tag::s(1, ""), + ], + ); +}