mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
2 files changed
+249
-29
No files matched your search
+198
-29
@@ -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(¥, "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<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] {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user