From 64a8c7f74cef70a851d1eb36ceced95318c24826 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:41:05 -0500 Subject: [PATCH] craft-dxf: faster ASCII writing and parse_recovering (ports of open CADCraft PRs) (#924) * dxf: write group codes, reals and plain text without the formatting machinery write_ascii and its code page forms wrote every group code through write!("{:>3}"), every real through a String of its own and every string character by character. A large drawing writes millions of each. Codes 0 to 9999 and reals are now appended in place, and text with no line break or backslash that is ASCII (or goes out as UTF-8) is copied as it is. Text for a code page still takes the long path, since a code page may write an ASCII character another way. The output is byte for byte what it was. Ported from storytold/cadcraft#1938 (the crates/dxf part). * dxf: parse_recovering reads a damaged or truncated ASCII file up to the damage parse fails a file at the first line that is no group code, and at a last group code with no value. parse_recovering returns the tags before that point and the line where reading stopped, so a caller can open what the file still holds and say where it ends. Reading stops at the damage: lines after it are not paired up by guesswork. Damage before the first group, a truncated binary file and the group-code limit are still errors. section_slices ends a section at EOF as well as ENDSEC, so the EOF of a file cut inside a section is not one of that section's records. Ported from storytold/cadcraft#1565 (the crates/dxf part). --------- Co-authored-by: Francesco BV Co-authored-by: Alex Ortiz <76929676+aljormon84@users.noreply.github.com> --- crates/dxf/src/lib.rs | 227 ++++++++++++++++++++++++++++----- crates/dxf/tests/recovering.rs | 51 ++++++++ 2 files changed, 249 insertions(+), 29 deletions(-) create mode 100644 crates/dxf/tests/recovering.rs diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 7694eb013..627cbfacb 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -199,6 +199,46 @@ pub fn parse(bytes: &[u8]) -> Result> { parse_with_limit(bytes, max_tags()) } +/// [`parse`] for a file that may be cut short or damaged: an ASCII file is read up to the first +/// line that is no group code (or a last group code with no value), and what came before is +/// returned with the 1-based number of that line, so a caller can open what the file still holds +/// and say where it stops. `None` for a file read to its end. A truncated binary file, an empty +/// one, one damaged before its first group, and one over the limit are still errors. +/// +/// # Errors +/// +/// As [`parse`], except for damage after the first group of an ASCII file. +pub fn parse_recovering(bytes: &[u8]) -> Result<(Vec, Option)> { + if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) { + return parse_binary_with_limit(body, max_tags()).map(|tags| (tags, None)); + } + let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes); + let (tags, limit) = (count_ascii_pairs(bytes), max_tags()); + if tags > limit { + return Err(DxfError::TooLarge { tags, limit }); + } + ascii_tags(&codepage::decode_ascii_file(bytes), limit, tags, true) +} + +/// The nonblank group-code/value pairs of an ASCII file (LF, CRLF and a lone CR each end a +/// line), up to EOF just as the parser stops there. Blank lines do not count. +fn count_ascii_pairs(bytes: &[u8]) -> usize { + 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; + } + } + 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. @@ -213,21 +253,8 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { 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; - } - } + // Counted first, before the text is decoded or any tag allocated. + let tags = count_ascii_pairs(bytes); if tags > max_tags { return Err(DxfError::TooLarge { tags, limit: max_tags }); } @@ -379,19 +406,39 @@ pub fn parse_ascii(text: &str) -> Result> { /// 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> { + ascii_tags(text, max_tags, expected, false).map(|(tags, _)| tags) +} + +/// The tags of ASCII DXF text. With `recover`, a line that is no group code, or a group code +/// with no value after it, ends the reading instead of failing it: the tags before it are +/// returned with the 1-based number of that line. +#[allow(clippy::while_let_loop)] +fn ascii_tags(text: &str, max_tags: usize, expected: usize, recover: bool) -> Result<(Vec, Option)> { 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(); + let mut damaged = None; 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 Ok(code) = ct.parse::() else { + if recover && !tags.is_empty() { + damaged = Some(ln + 1); + break; + } + return Err(DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() }); + }; + let Some((_, val)) = lines.next() else { + if recover && !tags.is_empty() { + damaged = Some(ln + 1); + break; + } + return Err(DxfError::Eof); + }; let v = match typed(code, val) { // Only text with a backslash can hold an escape; the rest moves in as it is. Value::Str(s) => Value::Str(unescape_owned(s)), @@ -409,7 +456,7 @@ fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result { @@ -542,8 +589,18 @@ fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) /// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point). pub fn format_real(v: f64) -> String { + let mut out = Vec::new(); + push_real(&mut out, v); + // What `push_real` writes is ASCII. + String::from_utf8(out).unwrap_or_default() +} + +/// [`format_real`] appended to `out`, without a string of its own (a large drawing writes +/// millions of reals). +fn push_real(out: &mut Vec, v: f64) { if !v.is_finite() { - return "0.0".into(); + out.extend_from_slice(b"0.0"); + return; } // Very large and very small values in exponent form, as AutoCAD writes them (`1.0E+20`): // plain digits would run to hundreds of characters. @@ -552,11 +609,36 @@ pub fn format_real(v: f64) -> String { let e = format!("{v:E}"); let (m, x) = e.split_once('E').unwrap_or((&e, "0")); let x: i32 = x.parse().unwrap_or(0); - let m = if m.contains('.') { m.to_string() } else { format!("{m}.0") }; - return format!("{m}E{}{:02}", if x < 0 { '-' } else { '+' }, x.abs()); + let point = if m.contains('.') { "" } else { ".0" }; + let _ = write!(out, "{m}{point}E{}{:02}", if x < 0 { '-' } else { '+' }, x.abs()); + return; + } + let start = out.len(); + let _ = write!(out, "{v}"); + // A finite real in this range prints as an optional sign, digits and an optional point. + if !out.get(start..).is_some_and(|s| s.contains(&b'.')) { + out.extend_from_slice(b".0"); + } +} + +/// A group code right-aligned to three characters and its line end, as `write!("{:>3}\r\n")` +/// writes it, without the formatting machinery for the codes DXF has (0 to 9999). +fn push_code(out: &mut Vec, code: i32) { + match u16::try_from(code) { + Ok(c) if c < 10_000 => { + let digits = [c / 1000, c / 100 % 10, c / 10 % 10, c % 10].map(|d| b'0' + u8::try_from(d % 10).unwrap_or(0)); + // The significant digits (at least one), padded to three. + let first = digits.iter().position(|&d| d != b'0').unwrap_or(3); + for _ in 1..first { + out.push(b' '); + } + out.extend_from_slice(digits.get(first..).unwrap_or_default()); + out.extend_from_slice(b"\r\n"); + } + _ => { + let _ = write!(out, "{code:>3}\r\n"); + } } - let s = format!("{v}"); - if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") } } /// Most characters one string group holds: longer text is split over several groups (MTEXT @@ -636,12 +718,23 @@ enum Strings { 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); + push_code(&mut out, t.code); match &t.value { + // Most text has no line breaks or backslashes and is ASCII (or goes out as UTF-8): + // copied as it is. A code page may write an ASCII character another way, so text + // for one takes the long path. + Value::Str(s) + if !s.contains(['\r', '\n', '\\']) + && (matches!(strings, Strings::Utf8) || (matches!(strings, Strings::Escaped(None)) && s.is_ascii())) => + { + out.extend_from_slice(s.as_bytes()); + } 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::Real(r) => push_real(&mut out, *r), + Value::Int(i) => { + let _ = write!(out, "{i}"); + } + Value::Bool(b) => out.push(if *b { b'1' } else { b'0' }), 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 @@ -852,7 +945,8 @@ pub fn section_slices(tags: &[Tag]) -> Vec> { let name = tags.get(i + 1).filter(|t| t.code == 2).and_then(Tag::str_ref).unwrap_or_default().trim_end(); let start = i + 2; let mut j = start; - while j < tags.len() && !tags.get(j).is_some_and(|t| is(t, "ENDSEC")) { + // A section cut short has no ENDSEC: the EOF is not one of its records. + while j < tags.len() && !tags.get(j).is_some_and(|t| is(t, "ENDSEC") || is(t, "EOF")) { j += 1; } out.push(SectionRef { name, tags: tags.get(start..j).unwrap_or(&[]) }); @@ -886,6 +980,81 @@ pub fn record_slices(tags: &[Tag]) -> Vec<(String, &[Tag])> { mod tests { use super::*; + /// The writer's own code and real formatting gives the text `format!` gave. + #[test] + fn fast_code_and_real_formatting_match_format() { + for code in [-1_000_000, -10, -1, 0, 1, 9, 10, 42, 99, 100, 330, 999, 1000, 1071, 9999, 10_000, 65_535, 1_000_000] { + let mut s = Vec::new(); + push_code(&mut s, code); + assert_eq!(String::from_utf8(s).unwrap(), format!("{code:>3}\r\n"), "{code}"); + } + let old = |v: f64| { + if !v.is_finite() { + return "0.0".to_string(); + } + let a = v.abs(); + if a != 0.0 && !(1e-10..1e16).contains(&a) { + let e = format!("{v:E}"); + let (m, x) = e.split_once('E').unwrap_or((&e, "0")); + let x: i32 = x.parse().unwrap_or(0); + let m = if m.contains('.') { m.to_string() } else { format!("{m}.0") }; + return format!("{m}E{}{:02}", if x < 0 { '-' } else { '+' }, x.abs()); + } + let s = format!("{v}"); + if s.contains('.') { s } else { format!("{s}.0") } + }; + for v in [ + 0.0, + -0.0, + 1.0, + -1.0, + 0.1, + 1.5e-7, + 123456789.0, + 1e15, + 1e16, + 1e21, + 1e300, + -1e-300, + 1e-10, + 9e-11, + f64::MAX, + f64::MIN_POSITIVE, + f64::NAN, + f64::INFINITY, + ] { + assert_eq!(format_real(v), old(v), "{v}"); + } + let tags = [ + Tag::f(10, 2.0), + Tag::i(70, -3), + Tag::new(290, Value::Bool(true)), + Tag::s(1, "a\r\nb"), + Tag::s(1, "é"), + Tag::s(1, "plain"), + Tag::s(1, "a\\U+0041"), + ]; + assert_eq!( + write_ascii_as(&tags, true), + " 10\r\n2.0\r\n 70\r\n-3\r\n290\r\n1\r\n 1\r\na b\r\n 1\r\né\r\n 1\r\nplain\r\n 1\r\na\\U+005CU+0041\r\n" + ); + assert_eq!(write_ascii_as(&tags, false).lines().nth(9), Some("\\U+00E9")); + // A code page that writes an ASCII character another way still does. + let yen = [Tag::s(1, "a\\b~")]; + assert_eq!(write_ascii_in_page(¥, "ANSI_932"), ascii_slow(¥, "ANSI_932")); + } + + /// What the writer gave before the fast path: every string through `push_string`. + fn ascii_slow(tags: &[Tag], code_page: &str) -> Vec { + let mut out = Vec::new(); + for t in tags { + let _ = write!(out, "{:>3}\r\n", t.code); + push_string(&t.str(), Strings::Escaped(codepage::encoder(code_page)), &mut out); + out.extend_from_slice(b"\r\n"); + } + out + } + #[test] fn literal_escape_text_roundtrips() { for utf8 in [false, true] { diff --git a/crates/dxf/tests/recovering.rs b/crates/dxf/tests/recovering.rs new file mode 100644 index 000000000..1dffd87f3 --- /dev/null +++ b/crates/dxf/tests/recovering.rs @@ -0,0 +1,51 @@ +//! `parse_recovering`: what a damaged or truncated file still holds. + +use craft_dxf::{DxfError, parse, parse_recovering, section_slices, write_binary}; + +const WHOLE: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\nA\n0\nENDSEC\n0\nEOF\n"; + +#[test] +fn an_intact_file_reads_as_parse_reads_it() { + let (tags, damaged) = parse_recovering(WHOLE.as_bytes()).expect("reads"); + assert_eq!(damaged, None); + assert_eq!(tags, parse(WHOLE.as_bytes()).expect("reads")); + // Binary files are read as before. + let binary = write_binary(&tags, true); + assert_eq!(parse_recovering(&binary).expect("reads"), (tags, None)); +} + +#[test] +fn a_line_that_is_no_group_code_ends_the_reading() { + let text = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\nA\n<>\nmore\n0\nCIRCLE\n"; + assert!(matches!(parse(text.as_bytes()), Err(DxfError::BadCode { line: 9, .. }))); + let (tags, damaged) = parse_recovering(text.as_bytes()).expect("what came before is kept"); + assert_eq!(damaged, Some(9)); + assert_eq!(tags.len(), 4, "nothing after the damage is guessed at"); + assert_eq!(tags.last().map(|t| (t.code, t.str())), Some((8, "A".to_string()))); +} + +#[test] +fn a_file_cut_after_a_group_code_keeps_the_groups_before_it() { + let text = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8"; + assert!(matches!(parse(text.as_bytes()), Err(DxfError::Eof))); + let (tags, damaged) = parse_recovering(text.as_bytes()).expect("what came before is kept"); + assert_eq!((tags.len(), damaged), (3, Some(7))); + // The section it was cut in still has its records. + let s = section_slices(&tags); + assert_eq!((s.len(), s[0].name, s[0].tags.len()), (1, "ENTITIES", 1)); +} + +#[test] +fn damage_before_the_first_group_is_still_an_error() { + assert!(matches!(parse_recovering(b"not a drawing\n"), Err(DxfError::BadCode { line: 1, .. }))); + assert!(matches!(parse_recovering(b"0"), Err(DxfError::Eof))); + assert!(matches!(parse_recovering(b""), Err(DxfError::NotDxf))); + assert!(matches!(parse_recovering(b"\n\n"), Err(DxfError::NotDxf))); +} + +#[test] +fn a_section_without_endsec_ends_at_eof() { + let tags = parse(b"0\nSECTION\n2\nENTITIES\n0\nLINE\n0\nEOF\n").expect("reads"); + let s = section_slices(&tags); + assert_eq!(s[0].tags.len(), 1, "the EOF is not a record of the section"); +}