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 <francesco.esposito@bvitalia.net>
Co-authored-by: Alex Ortiz <76929676+aljormon84@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-11 18:41:05 -05:00
1 parent 44363b5da4
commit 64a8c7f74c
2 files changed
+249 -29

No files matched your search

+198 -29
View File
@@ -199,6 +199,46 @@ pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
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<Tag>, Option<usize>)> {
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<Vec<Tag>> {
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<Vec<Tag>> {
/// 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<Vec<Tag>> {
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<Tag>, Option<usize>)> {
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::<i32>() 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<V
if tags.is_empty() {
return Err(DxfError::NotDxf);
}
Ok(tags)
Ok((tags, damaged))
}
struct Cur<'a> {
@@ -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<u8>, 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<u8>, 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<u8> {
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<SectionRef<'_>> {
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(&yen, "ANSI_932"), ascii_slow(&yen, "ANSI_932"));
}
/// What the writer gave before the fast path: every string through `push_string`.
fn ascii_slow(tags: &[Tag], code_page: &str) -> Vec<u8> {
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] {
+51
View File
@@ -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<<garbage>>\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");
}