From 28fe95fff00fef5d9f8232272219746d0556a4cb Mon Sep 17 00:00:00 2001 From: Rodrigo Gressler Date: Mon, 19 Oct 2026 16:36:13 -0300 Subject: [PATCH 1/8] DXF: save pre-2007 text in the file's code page instead of all \U+ escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before 2007, DXF text is in the code page `$DWGCODEPAGE` names. Since #169 every non-ASCII character was written as a `\U+XXXX` escape, so a Portuguese drawing saved at 2000/2004 had layers, blocks and text such as `A\U+00E7\U+00E3o` in the file. Characters the declared code page has are now written as its bytes (`Ação` in ANSI_1252); the others are still escaped, and an unknown code page still gives pure ASCII. The DWG bridge is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/codepage.rs | 24 +++++++++ crates/dxf/src/lib.rs | 79 ++++++++++++++++++++--------- crates/dxf/tests/code_page_write.rs | 57 +++++++++++++++++++++ crates/io/src/dxf_write.rs | 8 +-- crates/io/src/lib.rs | 11 ++-- crates/io/tests/codepages.rs | 30 +++++++++++ crates/io/tests/dxf_versions.rs | 16 ++++-- crates/io/tests/followups_626.rs | 7 ++- docs/file-format-parity.md | 5 +- docs/localization-parity.md | 4 +- 10 files changed, 197 insertions(+), 44 deletions(-) create mode 100644 crates/dxf/tests/code_page_write.rs diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs index 6f27b5be4..53f24c3b6 100644 --- a/crates/dxf/src/codepage.rs +++ b/crates/dxf/src/codepage.rs @@ -69,6 +69,30 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option { decoder(code_page).map(|d| decode(bytes, d).into_owned()) } +/// 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 { + Decoder::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()), + 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 { diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 82ecf55e8..e37e3e13e 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] use std::fmt::Write as _; +use std::io::Write as _; mod version; pub use version::Version; @@ -409,18 +410,41 @@ pub fn write_ascii(tags: &[Tag]) -> String { /// 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 mut out = String::with_capacity(tags.len() * 16); + 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 gives [`write_ascii`]'s pure ASCII output. +pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { + ascii(tags, Strings::Escaped(codepage::decoder(code_page))) +} + +/// 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) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })), - Value::Str(s) => escape_unicode(s, &mut out), - Value::Real(r) => out.push_str(&format_real(*r)), - Value::Int(i) => out.push_str(&i.to_string()), - Value::Bool(b) => out.push_str(&i64::from(*b).to_string()), - Value::Hex(h) => out.push_str(h), + 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) => out.extend_from_slice(h.as_bytes()), } - out.push_str("\r\n"); + out.extend_from_slice(b"\r\n"); } out } @@ -433,6 +457,16 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// 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::decoder(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 { @@ -445,13 +479,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { Value::Str(s) => s.clone(), v => v.as_str(), }; - let mut text = String::with_capacity(s.len()); - if utf8 { - text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })); - } else { - escape_unicode(&s, &mut text); - } - out.extend(text.bytes().filter(|&b| b != 0)); + 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 => { @@ -505,15 +535,18 @@ pub fn is_binary(bytes: &[u8]) -> bool { bytes.starts_with(BINARY_SENTINEL) } -/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII -/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes -/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces. -fn escape_unicode(s: &str, out: &mut String) { +/// 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. +fn push_string(s: &str, strings: Strings, out: &mut Vec) { for c in s.chars() { - match c { - '\r' | '\n' => out.push(' '), - c if c.is_ascii() => out.push(c), - c => { + match (c, strings) { + ('\r' | '\n', _) => out.push(b' '), + (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 codepage::encode_char(c, d, out) => {} + (c, Strings::Escaped(_)) => { let mut units = [0u16; 2]; for u in c.encode_utf16(&mut units) { let _ = write!(out, "\\U+{u:04X}"); diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs new file mode 100644 index 000000000..316f2edd5 --- /dev/null +++ b/crates/dxf/tests/code_page_write.rs @@ -0,0 +1,57 @@ +//! 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 cadcraft_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 the output is what `write_ascii` writes, line breaks included. +#[test] +fn unknown_code_page_is_pure_ascii() { + let tags = file("EBCDIC", "Ação\r\nfim"); + let bytes = write_ascii_in(&tags, "EBCDIC"); + assert!(bytes.is_ascii()); + assert_eq!(bytes, write_ascii(&tags).into_bytes()); + assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim")); +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 521654e3d..ec4b8595f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1234,10 +1234,10 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } -/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text -/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one); -/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`. -fn code_page(d: &Drawing, version: Version) -> String { +/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it, in which +/// a saved file's text is written (characters outside it as `\U+XXXX` escapes); otherwise, and +/// from 2007 (UTF-8 text), `ANSI_1252`. +pub(crate) fn code_page(d: &Drawing, version: Version) -> String { let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default(); let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some(); if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index ef56e6602..605298db9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -88,10 +88,13 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result match ext(name).as_str() { "dxf" | "" => { let tags = dxf_write::tags(d, version); - Ok(if opts.binary { - cadcraft_dxf::write_binary(&tags, version.utf8()) - } else { - cadcraft_dxf::write_ascii_as(&tags, version.utf8()).into_bytes() + // Before 2007 the text is in the code page the header declares. + let code_page = dxf_write::code_page(d, version); + Ok(match (opts.binary, version.utf8()) { + (true, true) => cadcraft_dxf::write_binary(&tags, true), + (true, false) => cadcraft_dxf::write_binary_in(&tags, &code_page), + (false, true) => cadcraft_dxf::write_ascii_as(&tags, true).into_bytes(), + (false, false) => cadcraft_dxf::write_ascii_in(&tags, &code_page), }) } // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without diff --git a/crates/io/tests/codepages.rs b/crates/io/tests/codepages.rs index 40d85b3f4..53c22e908 100644 --- a/crates/io/tests/codepages.rs +++ b/crates/io/tests/codepages.rs @@ -116,6 +116,36 @@ fn code_page_text_survives_a_round_trip() { assert_eq!(names(&back), ("Слой".into(), "Блок".into(), "Привет".into())); } +/// Saving a pre-2007 DXF writes the text in the file's code page, as the DXF Reference +/// describes: a Portuguese drawing keeps `Elétrica`, `Ação` and `Função` as ANSI_1252 bytes +/// instead of `\U+00E9` escapes, a Cyrillic one its ANSI_1251 bytes, and characters the code +/// page lacks are still escaped. ASCII and binary, R2000 and R2004, read back the same. +#[test] +fn saving_writes_text_in_the_code_page() { + use cadcraft_io::{SaveOptions, Version}; + let pt = ascii_dxf("AC1015", "ANSI_1252", b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"); + let ru = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"); + let cases: [(&[u8], (&str, &str, &str), &[&[u8]]); 2] = [ + (&pt, ("Elétrica", "Ação", "Função 图"), &[b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"]), + (&ru, ("Слой", "Блок", "Привет é"), &[b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"]), + ]; + for (file, want, raw) in cases { + let d = cadcraft_io::read_dxf(file).unwrap(); + assert_eq!(names(&d), (want.0.into(), want.1.into(), want.2.into())); + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let out = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + for r in raw { + assert!(out.windows(r.len()).any(|w| w == *r), "{version} binary={binary}: {}", String::from_utf8_lossy(r)); + } + assert!(!out.windows(7).any(|w| w == b"\\U+00E7"), "{version} binary={binary}: ç is written as its byte"); + let back = cadcraft_io::read_dxf(&out).unwrap(); + assert_eq!(names(&back), (want.0.into(), want.1.into(), want.2.into()), "{version} binary={binary}"); + } + } + } +} + /// A binary DXF: R2000 strings are in the code page, R2007+ strings UTF-8. #[test] fn binary_dxf_honours_the_code_page() { diff --git a/crates/io/tests/dxf_versions.rs b/crates/io/tests/dxf_versions.rs index 3fc2f1c73..eafd989e9 100644 --- a/crates/io/tests/dxf_versions.rs +++ b/crates/io/tests/dxf_versions.rs @@ -87,9 +87,11 @@ fn dxf_at_every_version_reads_back_the_same() { assert_eq!(acadver(&bytes), v.code(), "{what}"); assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}"); let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes()); - assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, \\U+ escapes before"); - if !v.utf8() && !binary { - assert!(bytes.is_ascii(), "{what}"); + assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page before"); + if !v.utf8() { + // ANSI_1252 has the Latin characters; the others are escaped. + let has = |b: &[u8]| bytes.windows(b.len()).any(|w| w == b); + assert!(has(b"\\U+56FE\\U+53F7 Caf\xe9 \xd8 \\U+D83D\\U+DE00"), "{what}"); } let back = read(&bytes, "x.dxf").unwrap(); assert_eq!(first_text(&back).as_deref(), Some(INTL), "{what}"); @@ -99,11 +101,15 @@ fn dxf_at_every_version_reads_back_the_same() { } } -/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote. +/// R2000 ASCII is the R2000 DXF CADCraft always wrote, tag for tag; only the text is in the +/// code page instead of all `\U+` escapes. #[test] fn r2000_ascii_is_unchanged() { let d = sample(); - assert_eq!(write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&d).into_bytes()); + let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(); + let escaped = write_dxf(&d); + assert_ne!(saved, escaped.as_bytes(), "Café is written in ANSI_1252"); + assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap()); } /// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the diff --git a/crates/io/tests/followups_626.rs b/crates/io/tests/followups_626.rs index a44a27eb4..10e355cd3 100644 --- a/crates/io/tests/followups_626.rs +++ b/crates/io/tests/followups_626.rs @@ -177,10 +177,9 @@ fn code_page_is_kept_before_2007() { .unwrap(); for v in [Version::R2000, Version::R2004] { let bytes = save(&d, "x.dxf", v); - assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes"); - let text = String::from_utf8(bytes).unwrap(); - assert_eq!(code_page(&text), "ANSI_1251", "{v:?}"); - let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert!(bytes.windows(6).any(|w| w == b"\xCF\xF0\xE8\xE2\xE5\xF2"), "{v:?}: the text in ANSI_1251"); + assert_eq!(code_page(&String::from_utf8_lossy(&bytes)), "ANSI_1251", "{v:?}"); + let back = cadcraft_io::read_dxf(&bytes).unwrap(); assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251"); assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет"))); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c598193d7..122b654a8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (DWG read leaves out orphaned DGN line style components) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (pre-2007 DXF text saved in its code page) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no | | R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) | | R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) | -| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | writes `\U+` escapes (#169), which is correct; declares `ANSI_1252` | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page gives pure ASCII with escapes (#169) | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type @@ -138,6 +138,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Pre-2007 DXF saves write text in the file's code page (`Ação` as ANSI_1252 bytes, not `A\U+00E7\U+00E3o`); characters outside it stay `\U+` escapes | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3dbd01e22..5c0574616 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,8 +31,8 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. English. For scale: about 1,160 unique capitalised string literals exist in `crates/engine` and `crates/ui-egui` (grep, 2026-10-10). - **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback - and FONTALT, so CJK characters render (#173). DXF text is written with `\U+` escapes so - non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode + and FONTALT, so CJK characters render (#173). Pre-2007 DXF text is written in the file's code page, + with `\U+` escapes for characters outside it, so non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining, lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines are still placed one glyph per code point: **no shaping** for Devanagari and other Indic From 936f08db1feba5515f5ea7d51a9d3a37dda3aecd Mon Sep 17 00:00:00 2001 From: Rodrigo Gressler Date: Mon, 19 Oct 2026 22:25:35 -0300 Subject: [PATCH 2/8] DXF: keep files that declare ASCII or UTF8 pure ASCII when saving A pre-2007 file whose header says $DWGCODEPAGE ASCII (or UTF8/UNICODE, which no pre-2007 reader expects) is still written with \U+ escapes only, instead of Windows-1252 (or UTF-8) bytes the header doesn't declare. Review of #1693. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/codepage.rs | 14 +++++++++++++- crates/dxf/src/lib.rs | 7 ++++--- crates/dxf/tests/code_page_write.rs | 18 +++++++++++------- crates/io/tests/codepages.rs | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs index 53f24c3b6..9377404d9 100644 --- a/crates/dxf/src/codepage.rs +++ b/crates/dxf/src/codepage.rs @@ -69,12 +69,24 @@ 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 { - Decoder::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()), + // 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, diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index e37e3e13e..0bbb4ee68 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -418,9 +418,10 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// 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 gives [`write_ascii`]'s pure ASCII output. +/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page) +/// give [`write_ascii`]'s pure ASCII output. pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec { - ascii(tags, Strings::Escaped(codepage::decoder(code_page))) + ascii(tags, Strings::Escaped(codepage::encoder(code_page))) } /// How a writer stores string values. @@ -463,7 +464,7 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { /// 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::decoder(code_page))) + binary(tags, Strings::Escaped(codepage::encoder(code_page))) } fn binary(tags: &[Tag], strings: Strings) -> Vec { diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 316f2edd5..67e7e7e57 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -46,12 +46,16 @@ fn text_is_written_in_the_code_page() { } } -/// Without a known code page the output is what `write_ascii` writes, line breaks included. +/// 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_code_page_is_pure_ascii() { - let tags = file("EBCDIC", "Ação\r\nfim"); - let bytes = write_ascii_in(&tags, "EBCDIC"); - assert!(bytes.is_ascii()); - assert_eq!(bytes, write_ascii(&tags).into_bytes()); - assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim")); +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"); + } } diff --git a/crates/io/tests/codepages.rs b/crates/io/tests/codepages.rs index 53c22e908..56f9af80f 100644 --- a/crates/io/tests/codepages.rs +++ b/crates/io/tests/codepages.rs @@ -146,6 +146,25 @@ fn saving_writes_text_in_the_code_page() { } } +/// A file whose header declares `ASCII` keeps that code page and stays ASCII when saved: its +/// non-ASCII text is written as `\U+` escapes, not as Windows-1252 bytes. +#[test] +fn saving_an_ascii_file_keeps_it_ascii() { + use cadcraft_io::{SaveOptions, Version}; + let d = cadcraft_io::read_dxf(&ascii_dxf("AC1015", "ASCII", b"El\\U+00E9trica", b"B", b"Eletrica \\U+00E9")).unwrap(); + assert_eq!(names(&d), ("Elétrica".into(), "B".into(), "Eletrica é".into())); + for version in [Version::R2000, Version::R2004] { + let ascii = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); + assert!(ascii.is_ascii(), "{version}"); + let binary = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).unwrap(); + for out in [&ascii, &binary] { + assert!(out.windows(14).any(|w| w == b"El\\U+00E9trica"), "{version}"); + assert!(out.windows(5).any(|w| w == b"ASCII"), "{version}: the header keeps its code page"); + assert_eq!(names(&cadcraft_io::read_dxf(out).unwrap()), ("Elétrica".into(), "B".into(), "Eletrica é".into()), "{version}"); + } + } +} + /// A binary DXF: R2000 strings are in the code page, R2007+ strings UTF-8. #[test] fn binary_dxf_honours_the_code_page() { From 0646dca86ef4624a6665b04025acaaa33e1a5c44 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:05:36 -0300 Subject: [PATCH 3/8] DXF: escape ASCII output whose code page bytes are valid UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ASCII DXF that is valid UTF-8 is read as UTF-8, so pre-2007 text such as СІ in ANSI_1251 (D1 B2) came back as U+0472. write_ascii_in now falls back to \U+ escapes for such a file; binary DXF, read by its header's code page, keeps the bytes. Reported by CodeRabbit on #1693. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/dxf/src/lib.rs | 10 ++++++++-- crates/dxf/tests/code_page_write.rs | 17 +++++++++++++++++ docs/file-format-parity.md | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 0bbb4ee68..72f834978 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -419,9 +419,15 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String { /// `$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. +/// 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 { - ascii(tags, Strings::Escaped(codepage::encoder(code_page))) + 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. diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 67e7e7e57..bec95b73a 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -59,3 +59,20 @@ fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() { 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")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6dd6f783f..d919cf6ba 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no | | R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) | | R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) | -| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page gives pure ASCII with escapes (#169) | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169) | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type From 4fdde4f30161059ee0ef49847a1fc3ef8f645ace Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:40:24 +0000 Subject: [PATCH 4/8] Update symlink target in write_atomic instead of replacing the link Found while testing crates/engine/src/atomic_write.rs:33-58 (write_atomic) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/atomic_write.rs | 84 +++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..c7cabe877 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -30,6 +30,9 @@ const MAX_TRIES: u32 = 32; /// are written through the retained handle and flushed, and the temporary is then renamed over /// `path`. On any failure the temporary is removed and the original `path` is left untouched. pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + // A symlinked destination is resolved so the link stays a link and its target is replaced. + let resolved = resolve_symlink(path)?; + let path = resolved.as_deref().unwrap_or(path); let permissions = match std::fs::symlink_metadata(path) { Ok(meta) if meta.is_file() => Some(meta.permissions()), Ok(_) => None, @@ -58,6 +61,41 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Ok(()) } +/// Resolve `path` when it is a symlink, or return `None` if it is not one. +/// +/// A dangling link (for example into an unavailable mount) cannot be canonicalized, so its target +/// is followed by hand with `read_link`, joining relative targets onto the link's directory. +fn resolve_symlink(path: &Path) -> io::Result> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => {} + _ => return Ok(None), + } + if let Ok(target) = std::fs::canonicalize(path) { + return Ok(Some(target)); + } + let mut current = path.to_path_buf(); + for _ in 0..40 { + let target = std::fs::read_link(¤t)?; + current = if target.is_absolute() { target } else { current.parent().map_or(target.clone(), |p| p.join(&target)) }; + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => {} + Ok(_) => return finish_target(¤t), + Err(e) if e.kind() == io::ErrorKind::NotFound => return finish_target(¤t), + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, "too many levels of symbolic links")) +} + +/// Build the write path for a resolved, non-link (possibly missing) target. +fn finish_target(current: &Path) -> io::Result> { + // The target's directory must exist; otherwise report the error rather than replacing the link. + let parent = current.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let parent = std::fs::canonicalize(parent)?; + let name = current.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "symlink target has no file name"))?; + Ok(Some(parent.join(name))) +} + /// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path. /// /// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing @@ -147,6 +185,52 @@ mod tests { assert_eq!(std::fs::read(&dest).unwrap(), b"private report"); std::fs::remove_dir_all(dir).unwrap(); } + #[cfg(unix)] + #[test] + fn symlinked_destination_updates_the_target() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-link-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let real = dir.join("real.dxf"); + let link = dir.join("link.dxf"); + std::fs::write(&real, b"old").unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&real, &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file"); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_destination_creates_the_target() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-dangle-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("mnt")).unwrap(); + let real = dir.join("mnt").join("real.dxf"); + let link = dir.join("link.dxf"); + symlink("mnt/real.dxf", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "symlink was replaced by a regular file"); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_with_missing_parent_errors_and_keeps_the_link() { + use std::os::unix::fs::symlink; + let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomnt-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&dir).unwrap(); + let link = dir.join("link.dxf"); + symlink("missing/real.dxf", &link).unwrap(); + assert!(write_atomic(&link, b"new").is_err()); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[cfg(unix)] #[test] fn private_temp_is_restricted_from_creation() { From b17ae78cec362ac42ccf8929afcc6593202a2a0d Mon Sep 17 00:00:00 2001 From: austin62jake <340374338+austin62jake@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:01:18 -0500 Subject: [PATCH 5/8] fix(fonts): place Middle and bottom-justified text as AutoCAD does (#2229) A justified TEXT's insertion point (group 10) is computed from its alignment point, and AutoCAD takes it from a DWG as it is (it recomputes it only for DXF). CADCraft placed a Middle (M) text at half its height and a bottom-justified one a third of its height below the baseline, whatever the string or font, so in AutoCAD those texts were drawn off their alignment points: 0.35 at height 2.5 for "Tg 1234 ABC" justified M, 0.09 for BL with Arial (#2229). - Middle (M) now centres the text on its own ink, descenders included, as AutoCAD does. Text that draws nothing keeps half the height. Middle Center (MC) and the others are unchanged. - Bottom justifications with a TrueType face put the alignment point at the face's descent (its descent over its cap height, the text height): 0.296 of the height for Arial, where AutoCAD puts it. The stroke font keeps a third, which matches AutoCAD's SHX fonts. `frame` (the DWG insertion point) and `place` (drawing) share the new `justify_offset`, so what is drawn and what is saved agree. With the reporter's commands, the saved points are now within 0.0006 of AutoCAD's: M -0.9032 (AutoCAD -0.9038), BL -19.2604 (-19.2599). Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/lib.rs | 156 ++++++++++++++++++++++++++++++------- crates/fonts/src/ttf.rs | 10 +++ docs/file-format-parity.md | 5 +- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 13af05646..8ca1bc62f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -502,6 +502,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 { origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot) } +/// How far below the baseline a bottom-justified text's alignment point lies: a third of the +/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an +/// outline font (#2229). +fn bottom_depth(font: &TextFont, height: f64) -> f64 { + match font { + TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height), + TextFont::Stroke => height / 3.0, + } +} + +/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline; +/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the +/// string's own extents, descenders included, not at half the height (#2229). +fn ink_middle(sh: &Shaped) -> Option { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite()); + let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))); + (lo <= hi).then_some((lo + hi) / 2.0) +} + +/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`. +fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 { + let dx = match h { + Align::Left | Align::Aligned | Align::Fit => 0.0, + Align::Center | Align::Middle => -sh.width / 2.0, + Align::Right => -sh.width, + }; + let dy = match (h, v) { + (Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0), + (_, VAlign::Baseline) => 0.0, + (_, VAlign::Bottom) => bottom_depth(font, height), + (_, VAlign::Middle) => -height / 2.0, + (_, VAlign::Top) => -height, + }; + Vec2::new(dx, dy) +} + /// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline /// start from that point (along the text), its rotation and its mirroring (backwards, upside /// down). Aligned and fit text are placed by their insertion point. @@ -512,25 +548,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) { let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2)); return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0)); } - let width = shape_line(font, s, height, p.width_factor, p.oblique).width; + let sh = shape_line(font, s, height, p.width_factor, p.oblique); let origin = match (p.h, p.v) { (Align::Left, VAlign::Baseline) => p.insert, _ => p.align_pt.unwrap_or(p.insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); - (origin, Vec2::new(dx, dy), rot, mirror) + (origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror) } /// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its @@ -563,28 +587,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { (Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert, _ => p.align_pt.unwrap_or(insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; - let local = Vec2::new(dx, dy); + let local = justify_offset(font, &sh, height, p.h, p.v); let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); let xf = |q: Vec2| { let l = q + local; origin + Vec2::new(l.x * mx, l.y * my).rotate(rot) }; sh.map(xf); - let bb = Bounds2::from_points( - [Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf), - ); + let depth = bottom_depth(font, height); + let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf)); (sh, bb) } @@ -797,3 +808,88 @@ mod ttf_tests { assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A')); } } + +#[cfg(test)] +mod justify_tests { + use super::*; + + const H: f64 = 2.5; + + /// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`. + fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) { + let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) }; + (place(font, s, &p).0, baseline_start(font, s, &p)) + } + + /// The lowest and highest point of placed text's ink. + fn ink_y(sh: &Shaped) -> (f64, f64) { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y); + ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))) + } + + /// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it, + /// not at half the height: lowercase and descenders move the baseline (#2229). + #[test] + fn a_middle_text_is_centred_on_its_ink() { + let font = TextFont::Stroke; + let mut baselines = Vec::new(); + for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] { + let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point"); + // The insertion point a DWG keeps (group 10) is the baseline start, below the ink's + // middle by as much as the unplaced text's ink middle is above its baseline. + let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0)); + assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}"); + baselines.push((s, start.y)); + } + // Capitals and digits span the height, so they stay at half of it. + assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}"); + // Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher. + for (s, y) in &baselines[1..] { + if *s != "Tg 1234 ABC" { + assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}"); + } + } + } + + /// Text that draws nothing has no ink to centre: it keeps half the height. + #[test] + fn a_middle_text_without_ink_keeps_half_the_height() { + let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline); + assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}"); + } + + /// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is + /// a third of the height above its alignment point, and Middle Center still uses half. + #[test] + fn stroke_font_bottom_and_middle_center_are_unchanged() { + let font = TextFont::Stroke; + for h in [Align::Left, Align::Center, Align::Right] { + let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom); + assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}"); + } + let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle); + assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}"); + } + + /// With a TrueType font, a bottom-justified text's alignment point is at the face's descent, + /// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height). + /// Skips when Arial isn't installed. + #[test] + fn a_bottom_justified_truetype_text_sits_on_its_descent() { + let Some(face) = ttf::find("Arial") else { return }; + let ratio = ttf::descent_ratio(&face).unwrap(); + assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); + let font = TextFont::Outline(face); + let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom); + assert!((start.y - ratio * H).abs() < 1e-9, "{start:?}"); + // The descenders reach the alignment point's line: h/3 left them 0.1 above it. + let (lo, _) = ink_y(&sh); + assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + // And Middle centres the ink, as with the stroke font. + let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{lo}..{hi}"); + } +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 6b4c5ebdd..9c1888aa8 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -709,6 +709,16 @@ impl OutlinePen for Pen { } } +/// How far below the baseline a face reaches, as a fraction of the text height (which is the +/// face's cap height): its descent over its cap height. A bottom-justified text's alignment +/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent. +pub fn descent_ratio(face: &Face) -> Option { + let f = face.font()?; + let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent); + let r = descent / cap_height(&f); + (r.is_finite() && r > 0.0 && r < 2.0).then_some(r) +} + /// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height /// of its "H", else an estimate from the ascent. fn cap_height(f: &FontRef) -> f64 { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2bfea4a51..51d4ac74c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a Middle or bottom-justified TEXT's insertion point as AutoCAD computes it) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | -| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) | +| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137); a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | TEXT placement: a Middle (M) text is centred on its own extents, descenders included, and a bottom-justified text with a TrueType font sits on the face's descent, as AutoCAD places them; the stroke font's bottom stays at a third of the height. A DWG's group 10 for these texts now matches AutoCAD's within 0.001 at height 2.5 (it was up to 0.35 off) (#2229) | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | From 263eba2ef6430c570cd3cce5c2266a495075d95f Mon Sep 17 00:00:00 2001 From: austin62jake <340374338+austin62jake@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:15:20 -0500 Subject: [PATCH 6/8] Test bottom-justified TrueType text with any common installed font The test used Arial only, so it skipped on Linux and macOS CI. It now uses the first of Arial, Helvetica, DejaVuSans, Verdana or Liberation Sans that is installed (as the fonts crate's other TrueType tests do), and keeps the Arial-specific checks for Arial. Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/lib.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8ca1bc62f..5bbae6883 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -875,21 +875,25 @@ mod justify_tests { /// With a TrueType font, a bottom-justified text's alignment point is at the face's descent, /// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height). - /// Skips when Arial isn't installed. + /// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have + /// one); skips without any. #[test] fn a_bottom_justified_truetype_text_sits_on_its_descent() { - let Some(face) = ttf::find("Arial") else { return }; + let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f))); + let Some((name, face)) = found else { return }; let ratio = ttf::descent_ratio(&face).unwrap(); - assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); let font = TextFont::Outline(face); let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom); - assert!((start.y - ratio * H).abs() < 1e-9, "{start:?}"); - // The descenders reach the alignment point's line: h/3 left them 0.1 above it. - let (lo, _) = ink_y(&sh); - assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}"); + if name == "Arial" { + assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); + // The descenders reach the alignment point's line: h/3 left them 0.1 above it. + let (lo, _) = ink_y(&sh); + assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + } // And Middle centres the ink, as with the stroke font. let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline); let (lo, hi) = ink_y(&sh); - assert!(((lo + hi) / 2.0).abs() < 1e-9, "{lo}..{hi}"); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}"); } } From 1e96a3d50c2e955da294a0d2dbf2bdb53c40d9f5 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:13:10 -0300 Subject: [PATCH 7/8] DXF: never let a trail byte 0x5C start an escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Shift-JIS, Big5 and GBK a double-byte character's trail byte can be 0x5C, the backslash (表 95 5C, ソ 83 5C, 許 B3 5C, 乗 81 5C). Written as its bytes right before U+ or M+, the file holds a backslash byte followed by U+, an escape for a reader that looks for escapes before it decodes the code page. CADCraft decodes first, so it read back right, but such a character there is now written as a \U+XXXX escape and the file reads the same either way. Elsewhere it stays as its bytes. ANSI_949 has no such trail byte. Suggested by austin62jake on #1693. --- crates/dxf/src/lib.rs | 23 ++++++++++++++++++++--- crates/dxf/tests/code_page_write.rs | 22 ++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index ec350adaa..7f40c8f32 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -547,18 +547,21 @@ pub fn is_binary(bytes: &[u8]) -> bool { /// 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). +/// 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 matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => { + ('\\', _) 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 codepage::encode_char(c, d, out) => {} + (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) { @@ -569,6 +572,20 @@ fn push_string(s: &str, strings: Strings, out: &mut Vec) { } } +/// `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 { diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs index 466a2b2a6..75d647c08 100644 --- a/crates/dxf/tests/code_page_write.rs +++ b/crates/dxf/tests/code_page_write.rs @@ -92,3 +92,25 @@ fn literal_escapes_survive_code_page_text() { } } } + +/// 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/docs/file-format-parity.md b/docs/file-format-parity.md index d919cf6ba..eda4a0872 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no | | R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) | | R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) | -| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169) | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169); a double-byte character whose trail byte is `\` (Shift-JIS, Big5, GBK) is escaped where `U+` or `M+` follows it | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type From 5bc93ccf18c6700964ef9df6ff4db3514437b641 Mon Sep 17 00:00:00 2001 From: Jonas <80707953+jonascloud@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:24:31 +0100 Subject: [PATCH 8/8] test(io): add independent DXF/DWG conformance and preservation matrices (#2237) * test(dxf): add deterministic codec and drawing round-trip matrices Exercise typed tags and model-space LINE/CIRCLE/TEXT through in-memory ASCII and binary DXF at every save version. Compare both reads with the original model and check independent handwritten byte/entity fixtures. Keep desired-behaviour tests for extrusion and ByBlock plot-style loss explicitly ignored until fixed. No production or docs changes. * test(dxf): independently verify common entity properties * test(io): add a broad DWG preservation matrix with focused pending regressions Share deterministic Drawing cases and exact assertions between DXF and DWG without reducing DXF coverage. Run representable DWG cases at all six save versions, twice in RAM. Test R2000 color/transparency and the single TEXT elevation as explicit format limits. Keep six independently reproduced loss regressions ignored without weakening comparisons. No production or docs changes. * test(io): close binary-width and referenced-linetype coverage gaps Check every integer group range against independently constructed binary payloads, catching shared reader/writer width mistakes. Resolve linetypes with Drawing lookup and compare layer linetypes too. Add missing/changed-reference self-tests and inherited-pattern DXF/DWG round trips. Reproduced the review counterexamples before and after; full CI passes with two nextest threads. Test-only changes. * test(io): expand DXF and DWG matrices across entities and drawing collections Add basic coverage for all 29 modeled entity kinds, block graphs and attributes, all ACI colors, richer properties/XDATA, sparse handles, headers, symbol tables, views, groups, layouts and foreign payloads. Preserve typed fields and authored references through two RAM saves. Add independent DXF groups and native DWG bridge checks, plus explicit refusal-or-preservation tests for unknown entities. Keep confirmed discrepancies and unresolved preservation contracts as focused ignored tests; native linetype angles and silently dropped unknown DWG entities are independently reproduced. Version limits and foreign object handle remapping are explicit contracts, not blanket normalization. Tests only; no production or docs changes. Full CI passes with two nextest threads: 2777 passed, 27 skipped. * test(io): cover bounded property domains and targeted field interactions Independently check all ACI values and transparency percentages, native layer/style flag combinations, and literal large handles. Add shared DXF/DWG cases for alignment with mirrored styles, inherited versus explicit zero properties, repeated nested attributes, and integer-boundary handles. Keep exhausted handle allocation separate: DWG refuses the save; a focused ignored DXF regression reproduces silent loss of all nine model entities. No production or documentation changes. Validation: full cargo xtask ci with NEXTEST_TEST_THREADS=2 passes all seven gates (2792 passed, 28 skipped). The pending DXF regression fails explicitly when run with --ignored. * test(dxf): separate chunk conformance from binary reader tolerance Address PR #2237 review: shared exact-tag round trips only use hex chunks within the documented 127-byte group limit. Independently inspect ASCII and binary writer bytes, reassemble payloads, and verify following-record framing. Keep full 0..255 length-byte read tolerance in handwritten fixtures, not as a claim of conforming writer output. Add two focused ignored regressions for oversized ASCII/binary output, reproduced with 128-byte payloads. They extend existing issue #1025. No production changes. Validation: cargo xtask ci passes all seven gates with nextest 0.9.150 and two test threads (2782 passed, 30 skipped). Both new pending checks fail explicitly when run with --ignored. * test(io): compare DWG dimension blocks through their references Anonymous dimension-block suffixes may change between drawing sessions (Autodesk documentation; ODA DWG section 20.4.52.1). Resolve their names through preserved dimension identities and keep strict checks for cache content, entity handles, block properties and reference integrity. Enable the pending dimensions test across all six DWG versions and two saves. Add a paired-cache fixture and comparator controls rejecting swapped/merged/dangling references, geometry and property changes, stale caches, and named-block renaming. Leave the shared comparator and DXF contract unchanged. Validation: cargo xtask ci (all seven stages; 2786 passed, 29 skipped); roundtrip matrix (211 passed, 26 ignored). Resolves the preservation-contract decision in #2257 (tracker #2238, B4); no production codec fix. * test(io): specify WIPEOUT explicit closure on roundtrip An implicit WIPEOUT boundary gains one explicit closing marker in DXF/DWG. Compare against independently authored explicit boundaries in both winding directions across all six versions and two saves; retain strict field and resave comparisons. Enable the two pending closure checks without changing the shared comparator. Distinguish this valid file representation from the grip-edit defect: the latter is fixed separately in PR #2284, with engine, outline and rendering regressions. No production change in this test-only branch. Validation: cargo xtask ci, all seven stages passed (2788 passed, 27 skipped); active roundtrip matrix 213 passed; remaining ignored checks reproduce 24 drawing failures and two codec failures. One unrelated HTTP control-server test failed transiently, then passed in isolation and in the full rerun. Related #2256 and tracker #2238 B3. * test(io): expose multiline ATTRIB alignment corruption Keep the original origin witness and add distinct nonzero TEXT/MTEXT anchors. An active R2018 ASCII/binary two-save check verifies world-space multiline geometry and value edits; this does not excuse corrupted fallback TEXT fields. Retain the strict pending #2255 check and add comparison of independently downgraded R2000/R2004 geometry. The observed Left -> Center change is a real reader error, not permitted alignment-point canonicalization. The reader hunk from existing PR #1942 makes the extended strict check pass in an isolated experiment; no competing production fix is included here. Validation of this test-only tree: all seven cargo xtask ci stages pass (2789 passed, 27 skipped); active matrix 214 passed; remaining 24 drawing and two codec diagnostics still fail. An unrelated HTTP control-server test failed transiently, then passed alone and in the complete rerun. Related #2255, #1942 and tracker #2238 B2. * test(io): separate ATTRIB value and legacy-placement guarantees Assert exact saved ATTRIB values, read_as and authored embedded MTEXT contents before and after value edits. A mutation test rejects corrupt fields, including a value/read_as pair whose rendered geometry remains unchanged. Make legacy placement an independently runnable pending regression instead of a callback behind the failing field comparator. Compare direct R2000/R2004 exports with exports after one/two R2018 roundtrips, both encodings in each stage, and verify that the primary single-line fallback actually survives. Keep all three contracts local and distinct; remove the old cross-module callback. The new legacy test fails directly on world-space geometry with the unchanged reader. Both pending ATTRIB tests pass with the reader hunk from existing PR #1942 in an isolated experiment (548 IO tests also pass). No production fix is included. Validation: all seven cargo xtask ci stages pass with NEXTEST_TEST_THREADS=1 (2790 passed, 28 skipped). Earlier parallel runs and one isolated retry hit pre-existing HTTP/busy control-server failures; no exclusions or test changes. Active matrix 215 passed; all 25 drawing and two codec pending diagnostics still fail. Addresses review of #2237; related #2255, #1942 and tracker #2238 B2. --------- Co-authored-by: cloudj --- crates/dxf/tests/conformance.rs | 145 +++++++ crates/dxf/tests/hex_chunks.rs | 119 ++++++ crates/dxf/tests/roundtrip.rs | 148 +++++++ crates/io/tests/it/main.rs | 1 + crates/io/tests/it/roundtrip/cases.rs | 308 ++++++++++++++ crates/io/tests/it/roundtrip/cases/blocks.rs | 234 +++++++++++ crates/io/tests/it/roundtrip/cases/complex.rs | 131 ++++++ .../io/tests/it/roundtrip/cases/geometry.rs | 301 +++++++++++++ .../io/tests/it/roundtrip/cases/properties.rs | 351 ++++++++++++++++ crates/io/tests/it/roundtrip/collections.rs | 263 ++++++++++++ crates/io/tests/it/roundtrip/compare.rs | 374 +++++++++++++++++ .../io/tests/it/roundtrip/dwg/conformance.rs | 87 ++++ .../io/tests/it/roundtrip/dwg/dimensions.rs | 210 ++++++++++ crates/io/tests/it/roundtrip/dwg/entities.rs | 171 ++++++++ crates/io/tests/it/roundtrip/dwg/extended.rs | 395 ++++++++++++++++++ crates/io/tests/it/roundtrip/dwg/mod.rs | 75 ++++ .../io/tests/it/roundtrip/dxf/attributes.rs | 163 ++++++++ crates/io/tests/it/roundtrip/dxf/common.rs | 197 +++++++++ .../io/tests/it/roundtrip/dxf/conformance.rs | 185 ++++++++ crates/io/tests/it/roundtrip/dxf/entities.rs | 121 ++++++ crates/io/tests/it/roundtrip/dxf/extended.rs | 334 +++++++++++++++ .../tests/it/roundtrip/dxf/geometry_groups.rs | 138 ++++++ crates/io/tests/it/roundtrip/dxf/mod.rs | 56 +++ .../io/tests/it/roundtrip/dxf/table_flags.rs | 128 ++++++ crates/io/tests/it/roundtrip/handle_limits.rs | 40 ++ crates/io/tests/it/roundtrip/mod.rs | 11 + crates/io/tests/it/roundtrip/unknown.rs | 106 +++++ 27 files changed, 4792 insertions(+) create mode 100644 crates/dxf/tests/conformance.rs create mode 100644 crates/dxf/tests/hex_chunks.rs create mode 100644 crates/dxf/tests/roundtrip.rs create mode 100644 crates/io/tests/it/roundtrip/cases.rs create mode 100644 crates/io/tests/it/roundtrip/cases/blocks.rs create mode 100644 crates/io/tests/it/roundtrip/cases/complex.rs create mode 100644 crates/io/tests/it/roundtrip/cases/geometry.rs create mode 100644 crates/io/tests/it/roundtrip/cases/properties.rs create mode 100644 crates/io/tests/it/roundtrip/collections.rs create mode 100644 crates/io/tests/it/roundtrip/compare.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/conformance.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/dimensions.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/entities.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/extended.rs create mode 100644 crates/io/tests/it/roundtrip/dwg/mod.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/attributes.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/common.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/conformance.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/entities.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/extended.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/geometry_groups.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/mod.rs create mode 100644 crates/io/tests/it/roundtrip/dxf/table_flags.rs create mode 100644 crates/io/tests/it/roundtrip/handle_limits.rs create mode 100644 crates/io/tests/it/roundtrip/mod.rs create mode 100644 crates/io/tests/it/roundtrip/unknown.rs diff --git a/crates/dxf/tests/conformance.rs b/crates/dxf/tests/conformance.rs new file mode 100644 index 000000000..089ed8063 --- /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 cadcraft_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/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs new file mode 100644 index 000000000..e3fdc6948 --- /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 cadcraft_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 000000000..acdcb7a78 --- /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 cadcraft_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, ""), + ], + ); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 5d6c343d0..7f6ad8f20 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -54,6 +54,7 @@ mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; mod region_roundtrip; +mod roundtrip; mod roundtrip_audit; mod skipped_records; mod sortents; diff --git a/crates/io/tests/it/roundtrip/cases.rs b/crates/io/tests/it/roundtrip/cases.rs new file mode 100644 index 000000000..a6031ec8f --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases.rs @@ -0,0 +1,308 @@ +//! Deterministic drawing inputs shared by DXF and DWG tests, independent of either writer. +//! Each family isolates a property so a known failure need not disable unrelated coverage. +//! Format/version limits and pending failures belong to the callers, not these generators. + +pub(super) mod blocks; +pub(super) mod complex; +pub(super) mod geometry; +pub(super) mod properties; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{ + AnnoScale, Circle, Common, Drawing, EntityKind, HAlign, Layer, Line, Linetype, Lineweight, RawTag, Space, Text, TextStyle, Transparency, VAlign, +}; +use cadcraft_geom::Vec3; + +pub(super) struct Case { + pub name: String, + pub drawing: Drawing, +} + +pub(super) fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Survey_Ä".into(), color: Color::Index(3), ..Layer::default() }); + d.linetypes.push(Linetype::simple("Dash_test", "Original synthetic dash pattern", &[2.0, -0.75, 0.0, -0.25])); + d.text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() }); + d +} + +pub(super) fn text() -> Text { + Text { + insert: Vec3::new(2.5, -3.75, 4.125), + align_pt: None, + height: 2.25, + value: "CAD Ø".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +pub(super) fn entity_case(name: &str, common: Common, kind: EntityKind) -> Case { + let mut drawing = drawing(); + drawing.add(&Space::Model, common, kind).unwrap(); + Case { name: name.into(), drawing } +} + +pub(super) fn common_case(name: &str, common: Common) -> Case { + let mut drawing = drawing(); + for kind in [ + EntityKind::Line(Line { a: Vec3::new(-1.25, 2.5, 3.75), b: Vec3::new(8.125, -13.5, 21.25) }), + EntityKind::Circle(Circle { center: Vec3::new(-7.25, 11.5, 4.75), radius: 3.125 }), + EntityKind::Text(text()), + ] { + drawing.add(&Space::Model, common.clone(), kind).unwrap(); + } + Case { name: name.into(), drawing } +} + +pub(super) fn empty_and_defaults() -> Vec { + vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())] +} + +pub(super) fn geometry() -> Vec { + let mut cases = Vec::new(); + for (name, a, b) in [ + ("zero-start", Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)), + ("zero-end", Vec3::new(-3.0, -2.0, -1.0), Vec3::ZERO), + ("small", Vec3::new(1e-9, -2e-9, 3e-9), Vec3::new(-4e-9, 5e-9, -6e-9)), + ("survey", Vec3::new(1e7 + 0.125, -2e7 - 0.25, 123.75), Vec3::new(1e7 + 0.5, -2e7 + 0.75, -42.5)), + ] { + for (direction, start, end) in [("forward", a, b), ("reverse", b, a)] { + cases.push(entity_case(&format!("line/{name}/{direction}"), Common::default(), EntityKind::Line(Line { a: start, b: end }))); + } + for radius in [1e-9, 0.125, 3.25, 1e8] { + cases.push(entity_case(&format!("circle/{name}/radius={radius}"), Common::default(), EntityKind::Circle(Circle { center: a, radius }))); + } + } + cases +} + +pub(super) fn layers() -> Vec { + ["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into() +} + +pub(super) fn layer_linetypes() -> Vec { + ["0", "Survey_Ä"] + .map(|layer| { + let mut case = common_case(&format!("common/layer={layer}/inherited-linetype"), Common { layer: layer.into(), ..Common::default() }); + case.drawing.layer_mut(layer).unwrap().linetype = "Dash_test".into(); + case + }) + .into() +} + +pub(super) fn colors() -> Vec { + [Color::ByLayer, Color::ByBlock] + .into_iter() + .chain((1..=255).map(Color::Index)) + .chain([Rgb(0, 0, 0), Rgb(255, 255, 255), Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb(17, 93, 241)].map(Color::True)) + .map(|color| common_case(&format!("common/color={color:?}"), Common { color, ..Common::default() })) + .collect() +} + +pub(super) fn linetypes() -> Vec { + ["ByLayer", "ByBlock", "Continuous", "Dash_test"] + .map(|linetype| common_case(&format!("common/linetype={linetype}"), Common { linetype: linetype.into(), ..Common::default() })) + .into() +} + +pub(super) fn lineweights() -> Vec { + [Lineweight::ByLayer, Lineweight::ByBlock, Lineweight::Default] + .into_iter() + .chain(Lineweight::STANDARD.map(Lineweight::Mm100)) + .map(|lineweight| common_case(&format!("common/lineweight={lineweight:?}"), Common { lineweight, ..Common::default() })) + .collect() +} + +pub(super) fn linetype_scales() -> Vec { + [0.125, 1.0, 3.25, 1e6].map(|ltscale| common_case(&format!("common/ltscale={ltscale}"), Common { ltscale, ..Common::default() })).into() +} + +pub(super) fn transparencies() -> Vec { + [Transparency::ByLayer, Transparency::ByBlock] + .into_iter() + .chain((0..=90).map(Transparency::Percent)) + .map(|transparency| common_case(&format!("common/transparency={transparency:?}"), Common { transparency, ..Common::default() })) + .collect() +} + +pub(super) fn visibility() -> Vec { + [false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into() +} + +pub(super) fn thicknesses() -> Vec { + [-2.5, 0.0, 3.75].map(|thickness| common_case(&format!("common/thickness={thickness}"), Common { thickness, ..Common::default() })).into() +} + +pub(super) fn plot_styles() -> Vec { + ["ByLayer", "Review"] + .map(|plot_style| common_case(&format!("common/plot_style={plot_style}"), Common { plot_style: plot_style.into(), ..Common::default() })) + .into() +} + +pub(super) fn byblock_plot_style() -> Vec { + vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })] +} + +pub(super) fn extrusions() -> Vec { + [Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 1.0, 0.0)] + .map(|extrusion| common_case(&format!("common/extrusion={extrusion:?}"), Common { extrusion, ..Common::default() })) + .into() +} + +pub(super) fn foreign_xdata() -> Vec { + let xdata = vec![ + RawTag { code: 1001, value: "ROUNDTRIP_TEST".into() }, + RawTag { code: 1000, value: "foreign text Ø 😀".into() }, + RawTag { code: 1070, value: "-17".into() }, + RawTag { code: 1040, value: "3.25".into() }, + RawTag { code: 1004, value: "00FF80".into() }, + ]; + vec![common_case("common/foreign-xdata", Common { xdata, ..Common::default() })] +} + +pub(super) fn common_interactions(plot_style: &str) -> Vec { + // Full local product; retain the original DXF case with a named plot style as well as + // an inherited-style counterpart, so a named-style loss does not hide other interactions. + let mut cases = Vec::new(); + for layer in ["0", "Survey_Ä"] { + for color in [Color::ByLayer, Color::ByBlock, Color::True(Rgb(21, 89, 233))] { + for visible in [false, true] { + let common = Common { + layer: layer.into(), + color, + visible, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + ltscale: 2.75, + transparency: Transparency::Percent(37), + thickness: -1.25, + plot_style: plot_style.into(), + ..Common::default() + }; + cases.push(common_case(&format!("common/product/{layer}/{color:?}/visible={visible}/plot_style={plot_style}"), common)); + } + } + } + cases +} + +pub(super) fn text_values() -> Vec { + [("empty", ""), ("whitespace", " a\tb "), ("unicode", "Café 图号 😀 e\u{301}")] + .map(|(name, value)| entity_case(&format!("text/value/{name}"), Common::default(), EntityKind::Text(Text { value: value.into(), ..text() }))) + .into() +} + +pub(super) fn text_literal_escapes() -> Vec { + vec![entity_case("text/value/literal-escapes", Common::default(), EntityKind::Text(Text { value: r"\U+0041 \M+183A4 \P %%c".into(), ..text() }))] +} + +pub(super) fn text_heights() -> Vec { + [1e-9, 0.125, 2.25, 1e5] + .map(|height| entity_case(&format!("text/height={height}"), Common::default(), EntityKind::Text(Text { height, ..text() }))) + .into() +} + +pub(super) fn text_rotations() -> Vec { + [0.0, -0.7, std::f64::consts::FRAC_PI_2, 5.9] + .map(|rotation| entity_case(&format!("text/rotation={rotation}"), Common::default(), EntityKind::Text(Text { rotation, ..text() }))) + .into() +} + +pub(super) fn text_widths() -> Vec { + [0.125, 1.0, 2.75] + .map(|width_factor| { + entity_case(&format!("text/width_factor={width_factor}"), Common::default(), EntityKind::Text(Text { width_factor, ..text() })) + }) + .into() +} + +pub(super) fn text_obliques() -> Vec { + [-0.3, 0.0, 0.4] + .map(|oblique| entity_case(&format!("text/oblique={oblique}"), Common::default(), EntityKind::Text(Text { oblique, ..text() }))) + .into() +} + +pub(super) fn text_styles() -> Vec { + ["Standard", "Notes"] + .map(|style| entity_case(&format!("text/style={style}"), Common::default(), EntityKind::Text(Text { style: style.into(), ..text() }))) + .into() +} + +pub(super) fn annotation_scales() -> Vec { + [vec![], vec![AnnoScale::unit()], vec![AnnoScale::unit(), AnnoScale::new("1:50", 1.0, 50.0)]] + .map(|scales| { + entity_case(&format!("text/anno_scales={scales:?}"), Common { anno_scales: scales, ..Common::default() }, EntityKind::Text(text())) + }) + .into() +} + +pub(super) fn coplanar_text_alignment() -> Vec { + // Construct additional valid single-plane inputs before serialization. Keep the original + // differing-elevation cases separately: DXF can store both XYZ points, DWG TEXT has one Z. + let mut cases = text_alignment(true); + for case in &mut cases { + case.name.push_str("/coplanar"); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + if let EntityKind::Text(t) = &mut e.kind { + t.align_pt = t.align_pt.map(|p| Vec3::new(p.x, p.y, t.insert.z)); + } + }); + } + } + cases +} + +pub(super) fn noncoplanar_text_alignment() -> Vec { + text_alignment(true) + .into_iter() + .filter(|case| case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z)))) + .collect() +} + +pub(super) fn text_alignment(with_point: bool) -> Vec { + let mut cases = Vec::new(); + let points = if with_point { vec![Some(Vec3::ZERO), Some(Vec3::new(17.25, -23.5, 6.75))] } else { vec![None] }; + for halign in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Aligned, HAlign::Middle, HAlign::Fit] { + for valign in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] { + if matches!(halign, HAlign::Aligned | HAlign::Middle | HAlign::Fit) && valign != VAlign::Baseline { + continue; + } + for &align_pt in &points { + // The unused second point of left-baseline TEXT has a separate DXF read fixture. + if halign == HAlign::Left && valign == VAlign::Baseline && align_pt.is_some() { + continue; + } + let mut t = Text { halign, valign, align_pt, rotation: 0.7, oblique: -0.2, width_factor: 1.25, style: "Notes".into(), ..text() }; + // Build a consistent baseline before serialization. No writer-derived expected + // data: the font layout API determines a valid input; both points compare exactly. + if let Some(anchor) = align_pt + && !matches!(halign, HAlign::Aligned | HAlign::Fit) + { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(anchor.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: false, + upside_down: false, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(anchor.z); + } + cases.push(entity_case(&format!("text/alignment/{halign:?}/{valign:?}/point={align_pt:?}"), Common::default(), EntityKind::Text(t))); + } + } + } + cases +} diff --git a/crates/io/tests/it/roundtrip/cases/blocks.rs b/crates/io/tests/it/roundtrip/cases/blocks.rs new file mode 100644 index 000000000..a1af6218a --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/blocks.rs @@ -0,0 +1,234 @@ +//! Stored block graphs and references, never expanded geometry. Definitions have independent +//! handles; forward, repeated and cyclic references are small bounded synthetic graphs. + +use std::sync::Arc; + +use cadcraft_color::Color; +use cadcraft_doc::{Attrib, AttribModes, AttribProps, Block, Common, Entity, EntityKind, Insert, Line, Lineweight, Space, Text}; +use cadcraft_geom::Vec3; + +use super::{Case, drawing, text}; + +pub(crate) fn insert(block: &str) -> Insert { + Insert { + block: block.into(), + insert: Vec3::new(1.25, -2.5, 3.75), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + } +} + +fn base(name: &str, reference: Insert) -> Case { + let mut drawing = drawing(); + let mut leaf = Block::new("Z_Leaf"); + leaf.entities.push(Entity::new(drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) }))); + drawing.blocks.insert(leaf.name.clone(), Arc::new(leaf)); + drawing.add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + Case { name: name.into(), drawing } +} + +pub(crate) fn transformations() -> Vec { + let mut cases = Vec::new(); + for scale in [Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 0.5, 3.0), Vec3::new(-1.0, 2.0, 1.0)] { + for rotation in [0.0, 0.7, 5.9] { + cases.push(base(&format!("insert/scale={scale:?}/rotation={rotation}"), Insert { scale, rotation, ..insert("Z_Leaf") })); + } + } + cases +} + +pub(crate) fn arrays() -> Vec { + [(1, 3), (4, 1), (3, 2)] + .map(|(cols, rows)| { + base(&format!("minsert/{cols}x{rows}"), Insert { cols, rows, col_spacing: 5.25, row_spacing: -3.75, rotation: 0.7, ..insert("Z_Leaf") }) + }) + .into() +} + +pub(crate) fn metadata() -> Vec { + let mut case = base("block/metadata", insert("Z_Leaf")); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.base = Vec3::new(4.0, -3.0, 2.0); + block.description = "Synthetic Ø 图".into(); + block.explodable = false; + block.units = 4; + vec![case] +} + +pub(crate) fn nested(cyclic: bool) -> Vec { + let mut case = base(&format!("block/nested/cyclic={cyclic}"), insert("A_Parent")); + let mut parent = Block::new("A_Parent"); + for x in [2.0, 5.0] { + parent + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(Insert { insert: Vec3::new(x, 0.0, 0.0), ..insert("Z_Leaf") }))); + } + if cyclic { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(insert("A_Parent")))); + } + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + vec![case] +} + +fn attribute(tag: &str) -> Attrib { + Attrib { + tag: tag.into(), + text: Text { insert: Vec3::ZERO, height: 1.0, value: tag.into(), ..text() }, + invisible: false, + constant: false, + prompt: String::new(), + props: AttribProps::default(), + multiline: None, + modes: AttribModes::default(), + } +} + +pub(crate) fn attributes(family: &str) -> Vec { + let mut cases = Vec::new(); + let variants = match family { + "flags" => 0..2, + "constant" => 2..4, + _ => 0..4, + }; + for variant in variants { + let mut definition = attribute("ID"); + definition.prompt = "Synthetic identifier".into(); + let mut value = attribute("ID"); + value.text.value = "Part Ø 图".into(); + match family { + "flags" | "constant" => { + definition.invisible = variant & 1 != 0; + definition.constant = variant & 2 != 0; + value.invisible = definition.invisible; + value.constant = definition.constant; + } + "modes" => { + definition.modes.verify = variant & 1 != 0; + definition.modes.preset = variant & 2 != 0; + } + "lock" => { + definition.modes.lock_position = variant & 1 != 0; + value.modes.lock_position = definition.modes.lock_position; + } + "overrides" => { + value.props = match variant { + 0 => AttribProps { layer: Some("Survey_Ä".into()), ..Default::default() }, + 1 => AttribProps { color: Some(Color::Index(3)), ..Default::default() }, + 2 => AttribProps { linetype: Some("Dash_test".into()), ..Default::default() }, + _ => AttribProps { lineweight: Some(Lineweight::Mm100(53)), ..Default::default() }, + }; + } + _ => panic!("unknown attribute family"), + } + let mut case = base(&format!("attributes/{family}/{variant}"), Insert { attribs: vec![value, attribute("SECOND")], ..insert("Z_Leaf") }); + let handle = case.drawing.new_handle(); + let second = case.drawing.new_handle(); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.entities.push(Entity::new(handle, EntityKind::AttDef(definition))); + block.entities.push(Entity::new(second, EntityKind::AttDef(attribute("SECOND")))); + cases.push(case); + } + cases +} + +/// Repeated nested instances must retain their own ordered values, including empty text and +/// explicit zero overrides. Depth changes the stored owner chain, not attribute identity. +pub(crate) fn nested_attributes() -> Vec { + [1, 3] + .map(|depth| { + let mut case = attributes("flags").remove(0); + case.name = format!("block/nested-attributes/depth={depth}"); + let mut child = "Z_Leaf".to_owned(); + for level in 0..depth { + let mut parent = Block::new(&format!("Parent_{level}")); + for instance in 0..2 { + let mut value = attribute("ID"); + value.text.value = if instance == 0 { String::new() } else { format!("level={level}/instance={instance} Ø") }; + value.props = if instance == 0 { + AttribProps::default() + } else { + AttribProps { + layer: Some("Survey_Ä".into()), + color: Some(Color::ByBlock), + linetype: Some("Continuous".into()), + lineweight: Some(Lineweight::Mm100(0)), + } + }; + parent.entities.push(Entity::new( + case.drawing.new_handle(), + EntityKind::Insert(Insert { + insert: Vec3::new(f64::from(instance) * 10.0, f64::from(level), 0.0), + attribs: vec![value, attribute("SECOND")], + ..insert(&child) + }), + )); + } + // Each definition owns its own ATTDEF handles, even when its tags are repeated. + for tag in ["ID", "SECOND"] { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::AttDef(attribute(tag)))); + } + child.clone_from(&parent.name); + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + } + let handle = case.drawing.model.iter().next().unwrap().handle; + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.block = child; + }); + case + }) + .into() +} + +pub(crate) fn multiline_attributes() -> Vec { + // Keep the original origin witness and distinguish the fallback TEXT's point from MTEXT's. + [(Vec3::ZERO, Vec3::ZERO), (Vec3::new(12.0, 7.0, 0.0), Vec3::new(20.0, 8.0, 0.0))] + .into_iter() + .map(|(text_point, mtext_point)| { + let mut case = attributes("flags").remove(0); + case.name = format!("attributes/multiline/text-point={text_point:?}"); + let decorate = |a: &mut Attrib| { + a.text.insert = text_point; + a.text.value = "first\nsecond".into(); + a.multiline = Some(cadcraft_doc::MultilineAttrib { + mtext: cadcraft_doc::MText { + insert: mtext_point, + height: 1.0, + width: 6.0, + contents: "first\\Psecond".into(), + ..super::geometry::mtext() + }, + read_as: "first\nsecond".into(), + }); + }; + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + decorate(i.attribs.first_mut().unwrap()); + }); + } + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + let handle = block.entities.iter().find(|e| matches!(&e.kind, EntityKind::AttDef(a) if a.tag == "ID")).unwrap().handle; + block.entities.modify(handle, |e| { + let EntityKind::AttDef(a) = &mut e.kind else { panic!("expected ATTDEF") }; + decorate(a); + }); + case + }) + .collect() +} + +pub(crate) fn unreferenced() -> Vec { + let mut case = base("block/unreferenced", insert("Z_Leaf")); + case.drawing.blocks.insert("Empty".into(), Arc::new(Block::new("Empty"))); + let mut unused = Block::new("Unused"); + unused.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + case.drawing.blocks.insert(unused.name.clone(), Arc::new(unused)); + vec![case] +} diff --git a/crates/io/tests/it/roundtrip/cases/complex.rs b/crates/io/tests/it/roundtrip/cases/complex.rs new file mode 100644 index 000000000..ab13f4800 --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/complex.rs @@ -0,0 +1,131 @@ +//! Compound and reference-bearing entities. Geometry and cached values are authored explicitly; +//! unsupported versions are selected by callers rather than normalized after reading. + +use super::{Case, entity_case, geometry}; +use cadcraft_doc::{ + Block, Common, DimKind, DimMarks, Dimension, Entity, EntityKind, Helix, HelixConstrain, Line, MLeader, MLine, MLineJust, MLineStyle, Region, + Tolerance, +}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use std::sync::Arc; + +pub(crate) fn helices() -> Vec { + [false, true] + .into_iter() + .flat_map(|ccw| { + [HelixConstrain::Height, HelixConstrain::Turns, HelixConstrain::TurnHeight].map(|constrain| { + entity_case( + &format!("helix/{ccw}/{constrain:?}"), + Common::default(), + EntityKind::Helix(Helix { + center: Vec3::new(1.25, -2.5, 3.75), + base_radius: 3.0, + top_radius: 1.5, + height: -7.5, + turns: 2.5, + start_angle: 0.0, + ccw, + constrain, + }), + ) + }) + }) + .collect() +} + +pub(crate) fn mlines() -> Vec { + [false, true] + .into_iter() + .flat_map(|closed| { + [MLineJust::Top, MLineJust::Zero, MLineJust::Bottom].map(|just| { + entity_case( + &format!("mline/{closed}/{just:?}"), + Common::default(), + EntityKind::MLine(MLine::new(&MLineStyle::default(), &[Vec2::ZERO, Vec2::new(5.0, 0.0), Vec2::new(5.0, 4.0)], closed, just, 2.5)), + ) + }) + }) + .collect() +} + +pub(crate) fn mleaders() -> Vec { + [false, true] + .map(|spline| { + entity_case( + &format!("mleader/spline={spline}"), + Common::default(), + EntityKind::MLeader(MLeader { + leaders: vec![vec![Vec3::ZERO, Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]], + landing: Vec3::new(5.0, 4.0, 0.0), + dogleg: 0.75, + text: Some(cadcraft_doc::MText { insert: Vec3::new(5.9, 4.0, 0.0), rotation: 0.0, ..geometry::mtext() }), + style: "Standard".into(), + arrow_size: 0.3, + spline, + block: None, + }), + ) + }) + .into() +} + +pub(crate) fn regions() -> Vec { + vec![entity_case( + "region/bulged-loop", + Common::default(), + EntityKind::Region(Region::from_loop(vec![ + PolyVertex::with_bulge(Vec2::ZERO, 0.5), + PolyVertex::new(Vec2::new(5.0, 0.0)), + PolyVertex::new(Vec2::new(5.0, 4.0)), + PolyVertex::new(Vec2::new(0.0, 4.0)), + ])), + )] +} + +pub(crate) fn tolerances() -> Vec { + vec![entity_case( + "tolerance/frame", + Common::default(), + EntityKind::Tolerance(Tolerance { + insert: Vec3::new(1.25, -2.5, 3.75), + dir: Vec3::new(0.0, 1.0, 0.0), + style: "ISO-25".into(), + text: "0.125%%vA%%vB".into(), + }), + )] +} + +pub(crate) fn dimensions() -> Vec { + [DimKind::Linear { rotation: 0.0 }, DimKind::Aligned] + .map(|kind| { + let mut case = entity_case( + &format!("dimension/{kind:?}"), + Common::default(), + EntityKind::Dimension(Dimension { + kind, + defpt: Vec3::new(0.0, 3.0, 0.0), + text_mid: Vec3::new(5.0, 4.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: "<> mm".into(), + style: "ISO-25".into(), + measurement: 10.0, + text_rotation: 0.0, + user_text_pos: true, + block: Some("*D42".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: DimMarks::default(), + }), + ); + let mut block = Block::new("*D42"); + block + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, 3.0, 0.0), b: Vec3::new(10.0, 3.0, 0.0) }))); + case.drawing.blocks.insert(block.name.clone(), Arc::new(block)); + case + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/cases/geometry.rs b/crates/io/tests/it/roundtrip/cases/geometry.rs new file mode 100644 index 000000000..35a2cc9dc --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/geometry.rs @@ -0,0 +1,301 @@ +//! Additional entity families with authored geometry and optional fields. No serializer output +//! is used to build an input. New failures are isolated by the format-specific test registrations. + +use cadcraft_doc::{ + Arc, Common, Ellipse, EntityKind, Face3d, Hatch, HatchLoop, Image, Leader, LwPolyline, MText, MTextColumnType, MTextColumns, Point, Polyline3d, + RayLine, Solid, Table, TableCell, Wipeout, +}; +use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; + +use super::{Case, entity_case}; + +fn case(name: &str, kind: EntityKind) -> Case { + entity_case(name, Common::default(), kind) +} + +pub(crate) fn arcs() -> Vec { + let mut cases = Vec::new(); + for radius in [1e-9, 0.125, 3.25, 1e8] { + for (start, end) in [(0.0, std::f64::consts::FRAC_PI_2), (0.7, 5.9), (5.9, 0.7)] { + cases.push(case( + &format!("arc/r={radius}/{start}..{end}"), + EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius, start, end }), + )); + } + } + cases +} + +pub(crate) fn ellipses() -> Vec { + [0.125, 0.5, 1.0] + .into_iter() + .flat_map(|ratio| { + [(0.0, std::f64::consts::TAU), (0.7, 5.9), (5.9, 0.7)].map(|(start, end)| { + case( + &format!("ellipse/{ratio}/{start}..{end}"), + EntityKind::Ellipse(Ellipse { center: Vec3::new(1.25, -2.5, 3.75), major: Vec3::new(3.0, 4.0, 0.0), ratio, start, end }), + ) + }) + }) + .collect() +} + +pub(crate) fn polylines(variable_width: bool) -> Vec { + let mut cases = Vec::new(); + for closed in [false, true] { + for plinegen in [false, true] { + for bulge in [-1.0, 0.0, 0.5, 1.0] { + cases.push(case( + &format!("lwpolyline/closed={closed}/plinegen={plinegen}/bulge={bulge}/widths={variable_width}"), + EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { + p: Vec2::new(1.25, -2.5), + bulge, + start_width: if variable_width { 0.125 } else { 0.0 }, + end_width: if variable_width { 0.75 } else { 0.0 }, + }, + PolyVertex::with_bulge(Vec2::new(5.0, 3.0), -bulge), + PolyVertex::new(Vec2::new(-2.0, 4.0)), + ], + closed, + plinegen, + const_width: if variable_width { 0.0 } else { 0.25 }, + elevation: -3.75, + }), + )); + } + } + } + cases +} + +pub(crate) fn polylines_3d() -> Vec { + [false, true] + .map(|closed| { + case( + &format!("polyline3d/closed={closed}"), + EntityKind::Polyline3d(Polyline3d { + points: vec![Vec3::new(1.25, -2.5, 3.75), Vec3::new(5.0, 6.0, -7.0), Vec3::new(-3.0, 4.0, 9.0)], + closed, + }), + ) + }) + .into() +} + +pub(crate) fn splines(rational: bool) -> Vec { + [1, 2, 3] + .map(|degree| { + let mut spline = Spline::from_control(vec![Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], degree); + if rational { + spline.weights = vec![1.0, 0.5, 2.0, 1.0]; + } + case(&format!("spline/degree={degree}/rational={rational}"), EntityKind::Spline(spline)) + }) + .into() +} + +pub(crate) fn spline_fit(closed: bool) -> Vec { + vec![case( + &format!("spline/fit/closed={closed}"), + EntityKind::Spline(Spline::from_fit(&[Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], closed)), + )] +} + +pub(crate) fn points_and_rays() -> Vec { + let base = Vec3::new(1.25, -2.5, 3.75); + vec![ + case("point", EntityKind::Point(Point { p: base, angle: 0.0 })), + case("ray", EntityKind::Ray(RayLine { base, dir: Vec3::new(0.0, 0.0, 1.0) })), + case("xline", EntityKind::XLine(RayLine { base, dir: Vec3::new(0.0, 1.0, 0.0) })), + ] +} + +pub(crate) fn faces() -> Vec { + let corners = [Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 2.0, 3.0), Vec3::new(1.0, 5.0, 3.0), Vec3::new(4.0, 5.0, 3.0)]; + let mut cases = vec![case("solid", EntityKind::Solid(Solid { corners })), case("trace", EntityKind::Trace(Solid { corners }))]; + for hidden_edges in 0..16 { + cases.push(case(&format!("face3d/hidden={hidden_edges}"), EntityKind::Face3d(Face3d { corners, hidden_edges }))); + } + cases +} + +pub(crate) fn mtext() -> MText { + MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Notes".into(), + contents: "first\\Psecond Ø 图 😀".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + } +} + +pub(crate) fn mtext_placement() -> Vec { + (1..=9) + .flat_map(|attach| { + [0.0, 0.7, 5.9] + .map(|rotation| case(&format!("mtext/attach={attach}/rotation={rotation}"), EntityKind::MText(MText { attach, rotation, ..mtext() }))) + }) + .collect() +} + +pub(crate) fn mtext_spacing() -> Vec { + [0.25, 1.0, 1.75, 4.0] + .into_iter() + .flat_map(|line_spacing| { + [false, true].map(|line_spacing_exact| { + case( + &format!("mtext/spacing={line_spacing}/exact={line_spacing_exact}"), + EntityKind::MText(MText { line_spacing, line_spacing_exact, ..mtext() }), + ) + }) + }) + .collect() +} + +pub(crate) fn mtext_contents() -> Vec { + ["", " a\tb ", "{\\H0.7x;\\S1/2;}\\Pnext", "Unicode Ø 图 😀 e\u{301}"] + .map(|contents| case(&format!("mtext/contents={contents:?}"), EntityKind::MText(MText { contents: contents.into(), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_carets() -> Vec { + vec![case("mtext/caret-space", EntityKind::MText(MText { contents: "\\pxi-3,l3,t3;1.\tA\\P2.\tB ^ C".into(), ..mtext() }))] +} + +pub(crate) fn mtext_long() -> Vec { + [249, 250, 251, 255, 256, 1024] + .map(|n| case(&format!("mtext/long={n}"), EntityKind::MText(MText { contents: "Ø😀".repeat(n), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_columns() -> Vec { + [ + MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 0.5, height: 7.5, ..Default::default() }, + MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 0.5, + height: 5.0, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: true, + ..Default::default() + }, + MTextColumns { kind: MTextColumnType::Dynamic, count: 1, gutter: 0.5, height: 4.0, auto_height: true, ..Default::default() }, + ] + .into_iter() + .map(|columns| case(&format!("mtext/columns={columns:?}"), EntityKind::MText(MText { columns: Some(columns), ..mtext() }))) + .collect() +} + +pub(crate) fn leaders(elevated: bool) -> Vec { + [false, true] + .into_iter() + .flat_map(|arrow| { + [false, true].map(|spline| { + case( + &format!("leader/arrow={arrow}/spline={spline}/elevated={elevated}"), + EntityKind::Leader(Leader { + vertices: vec![ + Vec3::ZERO, + Vec3::new(2.0, 3.0, if elevated { 4.0 } else { 0.0 }), + Vec3::new(5.0, 6.0, if elevated { 7.0 } else { 0.0 }), + ], + arrow, + spline, + style: "ISO-25".into(), + }), + ) + }) + }) + .collect() +} + +pub(crate) fn hatches() -> Vec { + let vertices = [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(0.0, 8.0)].map(PolyVertex::new).to_vec(); + (0..=2) + .map(|style| { + case( + &format!("hatch/solid/style={style}"), + EntityKind::Hatch(Hatch { + pattern: "SOLID".into(), + solid: true, + loops: vec![HatchLoop { vertices: vertices.clone(), outer: true, sources: Vec::new() }], + scale: 1.0, + angle: 0.0, + associative: false, + style, + elevation: 3.75, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + }), + ) + }) + .collect() +} + +pub(crate) fn tables() -> Vec { + vec![case( + "table/cells", + EntityKind::Table(Table { + insert: Vec3::new(1.25, -2.5, 0.0), + col_widths: vec![5.0, 7.5], + row_heights: vec![2.0, 3.0], + cells: vec![ + vec![TableCell { text: "Ø".into(), ..Default::default() }, TableCell { text: "图".into(), ..Default::default() }], + vec![TableCell::default(), TableCell { text: "3.25".into(), ..Default::default() }], + ], + style: "Standard".into(), + text_height: 1.25, + title: false, + header: false, + row_styles: Vec::new(), + breaks: None, + data_link: None, + }), + )] +} + +pub(crate) fn images() -> Vec { + vec![case( + "image/reference-only", + EntityKind::Image(Image { + path: "synthetic-not-loaded.png".into(), + name: "Synthetic".into(), + insert: Vec3::new(1.0, 2.0, 3.0), + u: Vec3::new(0.5, 0.0, 0.0), + v: Vec3::new(0.0, 0.25, 0.0), + size: Vec2::new(32.0, 16.0), + clip: vec![Vec2::new(2.0, 3.0), Vec2::new(20.0, 12.0)], + clipping: true, + brightness: 37, + contrast: 61, + fade: 25, + ..Image::default() + }), + )] +} + +pub(crate) fn wipeouts(explicit_closure: bool) -> Vec { + [false, true] + .into_iter() + .map(|reversed| { + let mut boundary = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)]; + if reversed { + boundary.reverse(); + } + if explicit_closure { + boundary.push(boundary[0]); + } + case(&format!("wipeout/triangle/explicit-closure={explicit_closure}/reversed={reversed}"), EntityKind::Wipeout(Wipeout { boundary })) + }) + .collect() +} diff --git a/crates/io/tests/it/roundtrip/cases/properties.rs b/crates/io/tests/it/roundtrip/cases/properties.rs new file mode 100644 index 000000000..eab95e8ac --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/properties.rs @@ -0,0 +1,351 @@ +//! Property, symbol-table and identity inputs. Values are authored before serialization; +//! each family can fail independently without suppressing unrelated preservation checks. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, DashElement, Entity, EntityKind, HAlign, Handle, Layer, Line, Lineweight, RawTag, Text, TextStyle, Transparency, VAlign}; +use cadcraft_geom::{Vec2, Vec3}; + +use super::{Case, common_case, drawing, entity_case, text}; + +pub(crate) fn mixed_case_references(kind: &str) -> Vec { + let mut common = Common::default(); + let mut t = text(); + match kind { + "layer" => common.layer = "sURVEY_Ä".into(), + "linetype" => common.linetype = "dASH_TEST".into(), + "style" => t.style = "nOTES".into(), + _ => panic!("unknown reference family"), + } + vec![entity_case(&format!("reference/{kind}/mixed-case"), common, EntityKind::Text(t))] +} + +fn layer_case(name: &str, layer: Layer) -> Case { + let mut case = common_case(name, Common { layer: "Survey_Ä".into(), ..Common::default() }); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { name: "Survey_Ä".into(), ..layer }; + case +} + +pub(crate) fn layer_flags() -> Vec { + (0..32) + .map(|bits| { + layer_case( + &format!("layer/flags={bits}"), + Layer { + on: bits & 1 == 0, + frozen: bits & 2 != 0, + locked: bits & 4 != 0, + plot: bits & 8 == 0, + vp_freeze_new: bits & 16 != 0, + ..Layer::default() + }, + ) + }) + .collect() +} + +pub(crate) fn layer_appearance() -> Vec { + [Color::Index(1), Color::Index(255), Color::True(Rgb(17, 93, 241))] + .into_iter() + .flat_map(|color| { + [0, 37, 90].map(|transparency| { + layer_case( + &format!("layer/{color:?}/alpha={transparency}"), + Layer { + color, + transparency, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + description: "Synthetic survey Ø 图".into(), + ..Layer::default() + }, + ) + }) + }) + .collect() +} + +pub(crate) fn layer_plot_styles() -> Vec { + ["Normal", "Review"] + .map(|plot_style| layer_case(&format!("layer/plot-style={plot_style}"), Layer { plot_style: plot_style.into(), ..Layer::default() })) + .into() +} + +fn style_case(name: &str, style: TextStyle) -> Case { + let mut case = entity_case(name, Common::default(), EntityKind::Text(Text { style: "Notes".into(), ..text() })); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = TextStyle { name: "Notes".into(), ..style }; + case +} + +pub(crate) fn text_style_metrics() -> Vec { + [0.0, 0.125, 3.5] + .into_iter() + .flat_map(|height| { + [0.5, 1.0, 2.75].map(|width_factor| { + style_case( + &format!("style/height={height}/width={width_factor}"), + TextStyle { height, width_factor, oblique: std::f64::consts::FRAC_PI_4, ..TextStyle::default() }, + ) + }) + }) + .collect() +} + +pub(crate) fn text_style_flags() -> Vec { + (0..16) + .map(|bits| { + style_case( + &format!("style/flags={bits}"), + TextStyle { + backwards: bits & 1 != 0, + upside_down: bits & 2 != 0, + vertical: bits & 4 != 0, + annotative: bits & 8 != 0, + ..TextStyle::default() + }, + ) + }) + .collect() +} + +pub(crate) fn text_style_fonts() -> Vec { + // Names only; no font files are read or required. + [("CADCraft Stroke", ""), ("SyntheticSans.ttf", ""), ("synthetic.shx", "synthetic-big.shx")] + .map(|(font, big_font)| { + style_case( + &format!("style/font={font}/big={big_font}"), + TextStyle { font: font.into(), big_font: big_font.into(), ..TextStyle::default() }, + ) + }) + .into() +} + +pub(crate) fn complex_linetypes(shape: bool) -> Vec { + [false, true] + .map(|absolute| { + let mut case = + common_case(&format!("linetype/shape={shape}/absolute={absolute}"), Common { linetype: "Dash_test".into(), ..Common::default() }); + if shape { + case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap().font = "synthetic.shx".into(); + } + let l = case.drawing.linetypes.iter_mut().find(|l| l.name == "Dash_test").unwrap(); + l.pattern.push(DashElement { + text: (!shape).then(|| "GAS Ø".into()), + shape: shape.then_some(7), + style: Some("Notes".into()), + scale: 0.75, + rotation: std::f64::consts::FRAC_PI_2, + offset: Vec2::new(0.125, -0.25), + absolute, + ..DashElement::dash(-1.0) + }); + case + }) + .into() +} + +fn xdata_case(name: &str, tags: Vec<(i32, String)>) -> Case { + common_case(name, Common { xdata: tags.into_iter().map(|(code, value)| RawTag { code, value }).collect(), ..Common::default() }) +} + +pub(crate) fn xdata_lists() -> Vec { + vec![xdata_case( + "xdata/nested-and-multiple-apps", + [ + (1001, "ROUNDTRIP_A"), + (1002, "{"), + (1000, "outer"), + (1002, "{"), + (1070, "-32768"), + (1002, "}"), + (1071, "2147483647"), + (1002, "}"), + (1001, "ROUNDTRIP_B"), + (1000, ""), + (1000, "repeated Ø"), + (1004, "00FF80"), + (1041, "0.125"), + (1042, "2.75"), + ] + .map(|(c, v)| (c, v.into())) + .into(), + )] +} + +pub(crate) fn xdata_points() -> Vec { + (1010..=1013) + .map(|code| { + xdata_case( + &format!("xdata/point={code}"), + vec![(1001, "ROUNDTRIP_POINTS".into()), (code, "1.25".into()), (code + 10, "-2.5".into()), (code + 20, "3.75".into())], + ) + }) + .collect() +} + +pub(crate) fn xdata_handles() -> Vec { + let mut case = common_case("xdata/handle-reference", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for &handle in &handles { + case.drawing.model.modify(handle, |e| { + e.common.xdata = + vec![RawTag { code: 1001, value: "ROUNDTRIP_HANDLES".into() }, RawTag { code: 1005, value: handles.last().unwrap().hex() }] + }); + } + vec![case] +} + +pub(crate) fn handles_and_order() -> Vec { + sparse_handles(&[0x1_0000, 0x1_0000_0000]) +} + +pub(crate) fn handles_near_integer_limits() -> Vec { + // Cross f64's exact-integer limit and signed i64's limit; leave room below u64::MAX + // for generated table/object handles rather than testing allocator exhaustion here. + sparse_handles(&[(1 << 53) - 0x80, (1 << 63) - 0x80, u64::MAX - 0x1_0000]) +} + +pub(crate) fn exhausted_handles() -> Case { + // The seed and writer's additional 0x1000 reservation leave no allocation room. + sparse_handles(&[u64::MAX - 0x2000]).remove(0) +} + +fn sparse_handles(bases: &[u64]) -> Vec { + bases + .iter() + .map(|&base| { + let mut drawing = drawing(); + for index in [5, 1, 9, 3, 2, 8, 4, 7, 6] { + let kind = if index % 2 == 0 { + EntityKind::Text(Text { value: format!("entity-{index}"), ..text() }) + } else { + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(index as f64, 2.0, 3.0) }) + }; + drawing.model.push(Entity::new(Handle(base + index * 17), kind)); + } + drawing.handseed = base + 0x1000; + Case { name: format!("handles/sparse/base={base:X}"), drawing } + }) + .collect() +} + +pub(crate) fn degenerate_geometry() -> Vec { + [Vec3::ZERO, Vec3::new(1e9, -1e9, 1e-9)] + .map(|p| entity_case(&format!("line/coincident={p:?}"), Common::default(), EntityKind::Line(Line { a: p, b: p }))) + .into() +} + +pub(crate) fn text_interactions() -> Vec { + let mut cases = Vec::new(); + for rotation in [-0.7, 0.0, 5.9] { + for width_factor in [0.125, 2.75] { + for oblique in [-0.3, 0.4] { + cases.push(entity_case( + &format!("text/product/{rotation}/{width_factor}/{oblique}"), + Common::default(), + EntityKind::Text(Text { + rotation, + width_factor, + oblique, + height: 0.125, + style: "Notes".into(), + value: " Ø 图 😀 ".into(), + ..text() + }), + )); + } + } + } + cases +} + +/// Four independently overridden properties on a deliberately non-default layer. The 16 masks +/// cover inheritance/override pairs without multiplying by every possible property value. +pub(crate) fn layer_inheritance_overrides() -> Vec { + (0..16) + .map(|mask| { + let common = Common { + layer: "Survey_Ä".into(), + color: if mask & 1 == 0 { Color::ByLayer } else { Color::True(Rgb(0, 0, 0)) }, + transparency: if mask & 2 == 0 { Transparency::ByLayer } else { Transparency::Percent(0) }, + lineweight: if mask & 4 == 0 { Lineweight::ByLayer } else { Lineweight::Mm100(0) }, + linetype: if mask & 8 == 0 { "ByLayer" } else { "Continuous" }.into(), + ..Common::default() + }; + let mut case = common_case(&format!("layer/inheritance-overrides={mask:04b}"), common); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { + name: "Survey_Ä".into(), + color: Color::Index(3), + transparency: 67, + lineweight: Lineweight::Mm100(53), + linetype: "Dash_test".into(), + ..Layer::default() + }; + case + }) + .collect() +} + +/// Mirrored styles affect baseline placement; their metrics must not overwrite the entity's +/// own height/width/oblique. Font layout constructs consistent inputs, never expected output. +pub(crate) fn alignment_style_interactions() -> Vec { + let mut cases = Vec::new(); + for (halign, valign) in [ + (HAlign::Left, VAlign::Top), + (HAlign::Center, VAlign::Middle), + (HAlign::Right, VAlign::Baseline), + (HAlign::Aligned, VAlign::Baseline), + (HAlign::Fit, VAlign::Baseline), + ] { + for flags in 0..4 { + let style = TextStyle { + name: "Notes".into(), + height: 7.0, + width_factor: 2.0, + oblique: 0.25, + backwards: flags & 1 != 0, + upside_down: flags & 2 != 0, + ..TextStyle::default() + }; + let mut t = Text { + insert: Vec3::new(-5.0, 3.0, 0.0), + align_pt: Some(Vec3::ZERO), + style: style.name.clone(), + halign, + valign, + height: 1.5, + width_factor: 0.75, + oblique: -0.125, + rotation: 0.7, + ..text() + }; + if !matches!(halign, HAlign::Aligned | HAlign::Fit) { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(Vec2::ZERO), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: style.backwards, + upside_down: style.upside_down, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(0.0); + } + let mut case = entity_case(&format!("text/alignment-style/{halign:?}/{valign:?}/flags={flags}"), Common::default(), EntityKind::Text(t)); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = style; + cases.push(case); + } + } + cases +} + +pub(crate) fn long_text() -> Vec { + [254, 255, 256, 1024] + .map(|length| { + entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() })) + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/collections.rs b/crates/io/tests/it/roundtrip/collections.rs new file mode 100644 index 000000000..9b9acf36b --- /dev/null +++ b/crates/io/tests/it/roundtrip/collections.rs @@ -0,0 +1,263 @@ +//! Drawing-collection contracts beyond model space. Assertions cover explicitly authored data, +//! not generated timestamps/extents, allocation seeds or extra implementation-owned objects. +//! Named tables are compared by identity; paper entities retain order and handles. These are +//! focused collection contracts, not a claim that every Drawing/session field is persistent. + +use super::{ + cases::{self, Case}, + compare, +}; +use cadcraft_color::Color; +use cadcraft_doc::{ + Common, Drawing, EntityKind, Group, HVal, Layer, LayerState, Layout, Line, Linetype, NamedView, Space, TextStyle, Ucs, Viewport, VportConfig, + VportTile, +}; +use cadcraft_geom::{Vec2, Vec3}; + +pub(super) fn foreign_records() -> Case { + use cadcraft_doc::{Handle, RawTag, foreign::ForeignObject}; + let mut drawing = cases::drawing(); + let foreign = std::sync::Arc::make_mut(&mut drawing.foreign); + foreign.dictionary.push(("SYNTHETIC_PAYLOAD".into(), Handle(0xA000))); + foreign.targets.insert(Handle(0xA001), ("NOD".into(), String::new())); + foreign.objects.push(ForeignObject { + handle: Handle(0xA000), + kind: "XRECORD".into(), + tags: [ + (5, "A000"), + (330, "A001"), + (100, "AcDbXrecord"), + (280, "1"), + (1, "Original Ø payload"), + (90, "-7"), + (310, "00FF80"), + (10, "1.25"), + (20, "-2.5"), + (30, "3.75"), + ] + .map(|(code, value)| RawTag { code, value: value.into() }) + .into(), + }); + drawing.handseed = 0xA100; + Case { name: "drawing/foreign-xrecord".into(), drawing } +} + +pub(super) fn assert_foreign_records(a: &Drawing, b: &Drawing, context: &str) { + // Rereads may add implementation-owned dictionaries; this case owns one explicit key. + let key = "SYNTHETIC_PAYLOAD"; + let (_, handle) = a.foreign.dictionary.iter().find(|(name, _)| name == key).expect("authored dictionary entry"); + let (_, target) = b.foreign.dictionary.iter().find(|(name, _)| name == key).unwrap_or_else(|| panic!("{context}.dictionary[{key}]: missing key")); + let source = a.foreign.objects.iter().find(|o| o.handle == *handle).unwrap(); + let back = b.foreign.objects.iter().find(|o| o.handle == *target).expect("dictionary target must resolve"); + assert_eq!(back.kind, source.kind, "{context}.object.kind"); + let owner = back.tags.iter().find(|t| t.code == 330).and_then(|t| cadcraft_doc::Handle::parse_hex(&t.value)).expect("XRECORD owner"); + assert_eq!(b.foreign.targets.get(&owner), Some(&("NOD".into(), String::new())), "{context}.object.owner"); + // Foreign's documented contract reallocates object handles and remaps references. + // Identity here is the NOD key and its resolved target, not the numeric allocation. + // Preserve every payload tag (including its order) after the subclass marker. + fn payload(tags: &[cadcraft_doc::RawTag]) -> &[cadcraft_doc::RawTag] { + let start = + tags.iter().position(|t| t.code == 100 && t.value == "AcDbXrecord").unwrap_or_else(|| panic!("missing XRECORD subclass: {tags:?}")); + &tags[start + 1..] + } + assert_eq!(payload(&back.tags), payload(&source.tags), "{context}.object.payload"); +} + +const HEADER_KEYS: &[&str] = &["LTSCALE", "CELTSCALE", "LUPREC", "AUPREC", "INSBASE", "TEXTSIZE", "CLAYER", "TEXTSTYLE"]; + +pub(super) fn headers() -> Case { + let mut drawing = cases::drawing(); + drawing.header.set_f64("LTSCALE", 2.75); + drawing.header.set_f64("CELTSCALE", 0.125); + drawing.header.set_i64("LUPREC", 7); + drawing.header.set_i64("AUPREC", 3); + drawing.header.set("INSBASE", HVal::Point(Vec3::new(1.25, -2.5, 3.75))); + drawing.header.set_f64("TEXTSIZE", 3.25); + + Case { name: "drawing/header-variables".into(), drawing } +} + +pub(super) fn named_header_reference(key: &str) -> Case { + let mut case = headers(); + case.name = format!("drawing/header-reference/{key}"); + case.drawing.header.set_str( + key, + match key { + "CLAYER" => "Survey_Ä", + "TEXTSTYLE" => "Notes", + _ => panic!("unknown header reference"), + }, + ); + case +} + +pub(super) fn assert_headers(a: &Drawing, b: &Drawing, context: &str) { + for key in HEADER_KEYS { + assert_eq!(b.header.get(key), a.header.get(key), "{context}.header.{key}"); + } +} + +pub(super) fn symbol_tables() -> Case { + let mut drawing = cases::drawing(); + drawing.layers.push(Layer { + name: "UnusedLayer".into(), + color: Color::Index(5), + locked: true, + description: "Unused Ø".into(), + ..Layer::default() + }); + drawing.linetypes.push(Linetype::simple("UnusedDash", "Unused synthetic", &[3.0, -0.5, 0.0, -0.25])); + drawing.text_styles.push(TextStyle { name: "UnusedStyle".into(), font: "SyntheticSans.ttf".into(), width_factor: 1.25, ..TextStyle::default() }); + Case { name: "drawing/unreferenced-tables".into(), drawing } +} + +pub(super) fn assert_symbol_tables(a: &Drawing, b: &Drawing, context: &str) { + for layer in &a.layers { + assert_eq!(b.layer(&layer.name), Some(layer), "{context}.layer[{}]", layer.name); + } + for linetype in &a.linetypes { + assert_eq!(b.linetype(&linetype.name), Some(linetype), "{context}.linetype[{}]", linetype.name); + } + for style in &a.text_styles { + assert_eq!(b.text_style(&style.name), Some(style), "{context}.style[{}]", style.name); + } +} + +pub(super) fn views_and_ucs() -> Case { + let mut drawing = cases::drawing(); + drawing.views.push(NamedView { name: "SurveyView".into(), center: Vec2::new(1.25, -2.5), height: 25.0, width: 40.0, ..NamedView::default() }); + drawing.ucss.push(Ucs { + name: "RotatedUCS".into(), + origin: Vec3::new(3.0, 4.0, 5.0), + x_axis: Vec3::new(0.0, 1.0, 0.0), + y_axis: Vec3::new(-1.0, 0.0, 0.0), + }); + Case { name: "drawing/views-and-ucs".into(), drawing } +} + +pub(super) fn assert_views_and_ucs(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.views, a.views, "{context}.views"); + assert_eq!(b.ucss, a.ucss, "{context}.ucss"); +} + +pub(super) fn vports() -> Case { + let mut drawing = cases::drawing(); + drawing.vports.push(VportConfig { + name: "Split".into(), + tiles: vec![ + VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 }, + VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 }, + ], + }); + Case { name: "drawing/tiled-viewports".into(), drawing } +} + +pub(super) fn assert_vports(a: &Drawing, b: &Drawing, context: &str) { + for viewport in &a.vports { + assert_eq!(b.vports.iter().find(|v| v.name == viewport.name), Some(viewport), "{context}.vport[{}]", viewport.name); + } +} + +pub(super) fn groups() -> Case { + let mut case = cases::common_case("drawing/groups", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + case.drawing.groups.push(Group { + name: "Synthetic".into(), + description: "Ordered overlapping membership Ø".into(), + selectable: true, + members: vec![handles[2], handles[0]], + }); + case.drawing.groups.push(Group { name: "Other".into(), description: String::new(), selectable: false, members: vec![handles[1], handles[2]] }); + case +} + +pub(super) fn assert_groups(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.groups.len(), a.groups.len(), "{context}.groups.len"); + for group in &a.groups { + assert_eq!(b.groups.iter().find(|g| g.name == group.name), Some(group), "{context}.group[{}]", group.name); + } +} + +pub(super) fn layer_states() -> Case { + let mut drawing = cases::drawing(); + let mut layers = drawing.layers.clone(); + for layer in &mut layers { + layer.on = false; + layer.locked = true; + } + drawing.layer_states.push(LayerState { name: "Saved".into(), layers }); + Case { name: "drawing/layer-state".into(), drawing } +} + +pub(super) fn assert_layer_states(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layer_states, a.layer_states, "{context}.layer_states"); +} + +pub(super) fn layouts() -> Case { + let mut drawing = cases::drawing(); + drawing.layouts = vec![Layout::new("Sheet_A", 1), Layout::new("Sheet_B", 2)]; + for (index, layout) in drawing.layouts.iter_mut().enumerate() { + layout.page.paper_mm = true; + // PageSetup stores portrait dimensions; landscape is a separate orientation flag. + layout.page.width_mm = 297.0; + layout.page.height_mm = 420.0; + layout.page.scale = 0.5; + layout.page.plot_area = "window".into(); + layout.page.plot_window = [1.0, 2.0, 201.0, 142.0]; + layout.view = Some((Vec2::new(10.0, 20.0), 100.0 + index as f64)); + } + drawing.active_layout = Some("Sheet_B".into()); + for name in ["Sheet_A", "Sheet_B"] { + drawing.add(&Space::Paper(name.into()), Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 20.0, 0.0) })).unwrap(); + drawing + .add( + &Space::Paper(name.into()), + Common::default(), + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 60.0, 0.0), + width: 80.0, + height: 50.0, + view_center: Vec2::new(5.0, 7.0), + view_height: 25.0, + id: 2, + locked: true, + frozen_layers: vec!["Survey_Ä".into()], + ..Viewport::default() + }), + ) + .unwrap(); + } + Case { name: "drawing/layouts-and-paper-entities".into(), drawing } +} + +#[test] +fn collection_assertions_reject_lost_authored_data() { + type Check = fn(&Drawing, &Drawing, &str); + type Mutate = fn(&mut Drawing); + let cases: [(Case, Check, Mutate); 6] = [ + (headers(), assert_headers, |d| d.header.set_f64("TEXTSIZE", 9.0)), + (symbol_tables(), assert_symbol_tables, |d| d.layers.retain(|l| l.name != "UnusedLayer")), + (views_and_ucs(), assert_views_and_ucs, |d| d.ucss.clear()), + (groups(), assert_groups, |d| d.groups.first_mut().unwrap().members.reverse()), + (layouts(), assert_layouts, |d| d.layouts.first_mut().unwrap().page.scale = 2.0), + (foreign_records(), assert_foreign_records, |d| std::sync::Arc::make_mut(&mut d.foreign).objects.clear()), + ]; + for (case, verify, mutate) in cases { + verify(&case.drawing, &case.drawing, &case.name); + let mut changed = case.drawing.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| verify(&case.drawing, &changed, &case.name)).is_err(), "{}: lost data must fail", case.name); + } +} + +pub(super) fn assert_layouts(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layouts.len(), a.layouts.len(), "{context}.layouts.len"); + assert_eq!(b.active_layout, a.active_layout, "{context}.active_layout"); + for layout in &a.layouts { + let back = b.layouts.iter().find(|l| l.name == layout.name).expect("layout must survive"); + assert_eq!(back.tab_order, layout.tab_order, "{context}.{}.tab_order", layout.name); + assert_eq!(back.page, layout.page, "{context}.{}.page", layout.name); + assert_eq!(back.view, layout.view, "{context}.{}.view", layout.name); + compare::entities(a, b, &layout.entities, &back.entities, &format!("{context}.layout[{}]", layout.name)); + } +} diff --git a/crates/io/tests/it/roundtrip/compare.rs b/crates/io/tests/it/roundtrip/compare.rs new file mode 100644 index 000000000..d4bd1541a --- /dev/null +++ b/crates/io/tests/it/roundtrip/compare.rs @@ -0,0 +1,374 @@ +//! Shared preservation assertions for ordered entities, authored blocks and referenced tables. +//! Every field of the explicitly supported entities is checked; converted angles alone allow +//! floating-point roundoff, never wrapping or geometric approximation. Blocks are visited once +//! as stored definitions, not recursively expanded, so cyclic references remain bounded. +//! Header defaults, generated objects and other drawing collections are outside this slice. + +use std::fmt::Debug; + +use cadcraft_doc::{Arc, Attrib, Block, Circle, Common, Drawing, Entity, EntityKind, EntityStore, Insert, Line, MText, Text}; + +fn equal(expected: &T, actual: &T, context: &str, field: &str) { + assert_eq!(actual, expected, "{context}.{field}"); +} + +fn angle(expected: f64, actual: f64, context: &str, field: &str) { + // Two floating-point unit conversions, not a geometric approximation or angle wrapping. + let tolerance = 8.0 * f64::EPSILON * expected.abs().max(1.0); + assert!( + expected.is_finite() && actual.is_finite() && (actual - expected).abs() <= tolerance, + "{context}.{field}: expected {expected:?}, actual {actual:?}, tolerance {tolerance:e}" + ); +} + +pub(super) fn model(expected: &Drawing, actual: &Drawing, context: &str) { + entities(expected, actual, &expected.model, &actual.model, &format!("{context}.model")); + for (name, block) in &expected.blocks { + let path = format!("{context}.block[{name}]"); + let back = actual.block(name).unwrap_or_else(|| panic!("{path}: missing block")); + let Block { + name, + annotative, + base, + entities: contents, + description, + xref_overlay, + xref_status, + anonymous, + xref_path, + explodable, + units, + array, + } = block.as_ref(); + equal(name, &back.name, &path, "name"); + equal(annotative, &back.annotative, &path, "annotative"); + equal(base, &back.base, &path, "base"); + equal(description, &back.description, &path, "description"); + equal(xref_overlay, &back.xref_overlay, &path, "xref_overlay"); + equal(xref_status, &back.xref_status, &path, "xref_status"); + equal(anonymous, &back.anonymous, &path, "anonymous"); + equal(xref_path, &back.xref_path, &path, "xref_path"); + equal(explodable, &back.explodable, &path, "explodable"); + equal(units, &back.units, &path, "units"); + equal(array, &back.array, &path, "array"); + entities(expected, actual, contents, &back.entities, &path); + } +} + +pub(super) fn entities(expected: &Drawing, actual: &Drawing, source: &EntityStore, back: &EntityStore, context: &str) { + equal(&source.len(), &back.len(), context, "len"); + for (index, (a, b)) in source.iter().zip(back.iter()).enumerate() { + let path = format!("{context}.entity[{index}]"); + entity(a, b, &path); + layer_reference(expected, actual, &a.common.layer, &path); + linetype_reference(expected, actual, &a.common.linetype, &path); + let dimension_style = match &a.kind { + EntityKind::Dimension(d) => Some(d.style.as_str()), + EntityKind::Leader(l) => Some(l.style.as_str()), + EntityKind::Tolerance(t) => Some(t.style.as_str()), + _ => None, + }; + if let Some(name) = dimension_style { + equal(&expected.dim_style(name), &actual.dim_style(name), &path, "referenced_dimension_style"); + if let Some(style) = expected.dim_style(name) { + equal(&expected.text_style(&style.text_style), &actual.text_style(&style.text_style), &path, "dimension_text_style"); + } + } + let style = match &a.kind { + EntityKind::Text(t) => Some(t.style.as_str()), + EntityKind::MText(t) => Some(t.style.as_str()), + EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.style.as_str()), + _ => None, + }; + if let Some(style) = style { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + let attributes: &[Attrib] = match &a.kind { + EntityKind::AttDef(a) => std::slice::from_ref(a), + EntityKind::Insert(i) => &i.attribs, + EntityKind::MLeader(m) => m.block.as_ref().map_or(&[], |i| i.attribs.as_slice()), + _ => &[], + }; + for (index, a) in attributes.iter().enumerate() { + let path = format!("{path}.attrib[{index}]"); + if let Some(name) = &a.props.layer { + layer_reference(expected, actual, name, &path); + } + if let Some(name) = &a.props.linetype { + linetype_reference(expected, actual, name, &path); + } + for style in std::iter::once(a.text.style.as_str()).chain(a.multiline.as_ref().map(|m| m.mtext.style.as_str())) { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + } + } +} + +fn layer_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + let layer = expected.layer(name); + equal(&layer, &actual.layer(name), context, "referenced_layer"); + if let Some(layer) = layer { + linetype_reference(expected, actual, &layer.linetype, context); + } +} + +fn linetype_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + linetype(expected.linetype(name), actual.linetype(name), &format!("{context}.linetype[{name}]")); + if let Some(linetype) = expected.linetype(name) { + for element in &linetype.pattern { + if let Some(style) = &element.style { + equal(&expected.text_style(style), &actual.text_style(style), context, "linetype_text_style"); + } + } + } +} + +fn linetype(expected: Option<&cadcraft_doc::Linetype>, actual: Option<&cadcraft_doc::Linetype>, context: &str) { + let (Some(a), Some(b)) = (expected, actual) else { + equal(&expected, &actual, context, "presence"); + return; + }; + let cadcraft_doc::Linetype { name, description, pattern } = a; + equal(name, &b.name, context, "name"); + equal(description, &b.description, context, "description"); + equal(&pattern.len(), &b.pattern.len(), context, "pattern.len"); + for (index, (a, b)) in pattern.iter().zip(&b.pattern).enumerate() { + let path = format!("{context}.element[{index}]"); + let cadcraft_doc::DashElement { length, text, shape, style, scale, rotation, offset, absolute } = a; + equal(length, &b.length, &path, "length"); + equal(text, &b.text, &path, "text"); + equal(shape, &b.shape, &path, "shape"); + equal(style, &b.style, &path, "style"); + equal(scale, &b.scale, &path, "scale"); + angle(*rotation, b.rotation, &path, "rotation"); + equal(offset, &b.offset, &path, "offset"); + equal(absolute, &b.absolute, &path, "absolute"); + } +} + +fn entity(expected: &Entity, actual: &Entity, context: &str) { + let Entity { handle, common: properties, kind } = expected; + equal(handle, &actual.handle, context, "handle"); + common(properties, &actual.common, &format!("{context}.common")); + match (kind, &actual.kind) { + (EntityKind::Line(Line { a, b }), EntityKind::Line(other)) => { + equal(a, &other.a, context, "line.a"); + equal(b, &other.b, context, "line.b"); + } + (EntityKind::Circle(Circle { center, radius }), EntityKind::Circle(other)) => { + equal(center, &other.center, context, "circle.center"); + equal(radius, &other.radius, context, "circle.radius"); + } + (EntityKind::Text(a), EntityKind::Text(b)) => text(a, b, &format!("{context}.text")), + (EntityKind::Arc(Arc { center, radius, start, end }), EntityKind::Arc(b)) => { + equal(center, &b.center, context, "arc.center"); + equal(radius, &b.radius, context, "arc.radius"); + angle(*start, b.start, context, "arc.start"); + angle(*end, b.end, context, "arc.end"); + } + (EntityKind::MText(a), EntityKind::MText(b)) => mtext(a, b, context), + (EntityKind::AttDef(a), EntityKind::AttDef(b)) => attrib(a, b, context), + (EntityKind::Insert(a), EntityKind::Insert(b)) => insert(a, b, context), + ( + EntityKind::Point(_) + | EntityKind::Ellipse(_) + | EntityKind::LwPolyline(_) + | EntityKind::Polyline3d(_) + | EntityKind::Spline(_) + | EntityKind::Ray(_) + | EntityKind::XLine(_) + | EntityKind::Solid(_) + | EntityKind::Trace(_) + | EntityKind::Face3d(_) + | EntityKind::Leader(_) + | EntityKind::Hatch(_) + | EntityKind::Table(_) + | EntityKind::Image(_) + | EntityKind::Wipeout(_) + | EntityKind::Helix(_) + | EntityKind::MLine(_) + | EntityKind::MLeader(_) + | EntityKind::Region(_) + | EntityKind::Dimension(_) + | EntityKind::Tolerance(_) + | EntityKind::Viewport(_), + _, + ) => equal(kind, &actual.kind, context, "kind"), + _ => panic!("{context}.kind: unsupported or changed type: expected {kind:?}, actual {:?}", actual.kind), + } +} + +fn common(expected: &Common, actual: &Common, context: &str) { + // No `..`: adding a field requires deciding its preservation contract here. + let Common { layer, color, linetype, lineweight, ltscale, transparency, visible, thickness, extrusion, plot_style, anno_scales, xdata } = + expected; + equal(layer, &actual.layer, context, "layer"); + equal(color, &actual.color, context, "color"); + equal(linetype, &actual.linetype, context, "linetype"); + equal(lineweight, &actual.lineweight, context, "lineweight"); + equal(ltscale, &actual.ltscale, context, "ltscale"); + equal(transparency, &actual.transparency, context, "transparency"); + equal(visible, &actual.visible, context, "visible"); + equal(thickness, &actual.thickness, context, "thickness"); + equal(extrusion, &actual.extrusion, context, "extrusion"); + equal(plot_style, &actual.plot_style, context, "plot_style"); + equal(anno_scales, &actual.anno_scales, context, "anno_scales"); + equal(xdata, &actual.xdata, context, "xdata"); +} + +fn text(expected: &Text, actual: &Text, context: &str) { + let Text { insert, align_pt, height, value, rotation, width_factor, oblique, style, halign, valign } = expected; + equal(insert, &actual.insert, context, "insert"); + equal(align_pt, &actual.align_pt, context, "align_pt"); + equal(height, &actual.height, context, "height"); + equal(value, &actual.value, context, "value"); + angle(*rotation, actual.rotation, context, "rotation"); + equal(width_factor, &actual.width_factor, context, "width_factor"); + angle(*oblique, actual.oblique, context, "oblique"); + equal(style, &actual.style, context, "style"); + equal(halign, &actual.halign, context, "halign"); + equal(valign, &actual.valign, context, "valign"); +} + +fn mtext(expected: &MText, actual: &MText, context: &str) { + let MText { insert, height, width, attach, rotation, style, contents, line_spacing, line_spacing_exact, columns } = expected; + equal(insert, &actual.insert, context, "mtext.insert"); + equal(height, &actual.height, context, "mtext.height"); + equal(width, &actual.width, context, "mtext.width"); + equal(attach, &actual.attach, context, "mtext.attach"); + angle(*rotation, actual.rotation, context, "mtext.rotation"); + equal(style, &actual.style, context, "mtext.style"); + equal(contents, &actual.contents, context, "mtext.contents"); + equal(line_spacing, &actual.line_spacing, context, "mtext.line_spacing"); + equal(line_spacing_exact, &actual.line_spacing_exact, context, "mtext.line_spacing_exact"); + equal(columns, &actual.columns, context, "mtext.columns"); +} + +fn attrib(expected: &Attrib, actual: &Attrib, context: &str) { + let Attrib { tag, text: t, invisible, constant, prompt, props, multiline, modes } = expected; + equal(tag, &actual.tag, context, "attrib.tag"); + text(t, &actual.text, context); + equal(invisible, &actual.invisible, context, "attrib.invisible"); + equal(constant, &actual.constant, context, "attrib.constant"); + equal(prompt, &actual.prompt, context, "attrib.prompt"); + equal(props, &actual.props, context, "attrib.props"); + equal(modes, &actual.modes, context, "attrib.modes"); + match (multiline, &actual.multiline) { + (Some(a), Some(b)) => { + equal(&a.read_as, &b.read_as, context, "attrib.read_as"); + mtext(&a.mtext, &b.mtext, context); + } + _ => equal(multiline, &actual.multiline, context, "attrib.multiline"), + } +} + +fn insert(expected: &Insert, actual: &Insert, context: &str) { + let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing } = expected; + equal(block, &actual.block, context, "insert.block"); + equal(insert, &actual.insert, context, "insert.insert"); + equal(scale, &actual.scale, context, "insert.scale"); + angle(*rotation, actual.rotation, context, "insert.rotation"); + equal(cols, &actual.cols, context, "insert.cols"); + equal(rows, &actual.rows, context, "insert.rows"); + equal(col_spacing, &actual.col_spacing, context, "insert.col_spacing"); + equal(row_spacing, &actual.row_spacing, context, "insert.row_spacing"); + equal(&attribs.len(), &actual.attribs.len(), context, "insert.attribs.len"); + for (index, (a, b)) in attribs.iter().zip(&actual.attribs).enumerate() { + attrib(a, b, &format!("{context}.attrib[{index}]")); + } +} + +#[test] +fn comparison_rejects_lost_fields_and_changed_handles() { + use cadcraft_doc::Handle; + use cadcraft_geom::Vec3; + + let original = Entity::new(Handle(0x123), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) })); + let mutations: [fn(&mut Entity); 4] = [ + |e| e.handle = Handle(0x456), + |e| e.common.visible = false, + |e| e.common.xdata.push(cadcraft_doc::RawTag { code: 1000, value: "unexpected".into() }), + |e| e.kind = EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 2.0 }), + ]; + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| entity(&original, &changed, "comparison self-test")).is_err()); + } +} + +#[test] +fn comparison_rejects_lost_or_changed_referenced_linetypes() { + use cadcraft_doc::{Linetype, Space}; + use cadcraft_geom::Vec3; + + for (name, entity_linetype, layer_linetype) in [ + ("direct", "ReviewDashed", "Continuous"), + ("mixed-case", "reviewdashed", "Continuous"), + ("inherited", "ByLayer", "ReviewDashed"), + ("inherited-mixed-case", "ByLayer", "reviewdashed"), + ] { + let mut original = Drawing::new_metric(); + original.linetypes.push(Linetype::simple("ReviewDashed", "Synthetic", &[2.0, -1.0])); + original.layer_mut("0").unwrap().linetype = layer_linetype.into(); + original + .add( + &Space::Model, + Common { linetype: entity_linetype.into(), ..Common::default() }, + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 3.0) }), + ) + .unwrap(); + let referenced = if entity_linetype == "ByLayer" { layer_linetype } else { entity_linetype }; + assert!(original.linetype(referenced).is_some(), "{name}: valid reference"); + model(&original, &original, name); + let mutations: [fn(&mut Drawing); 2] = [ + |d| d.linetypes.retain(|l| l.name != "ReviewDashed"), + |d| d.linetypes.iter_mut().find(|l| l.name == "ReviewDashed").unwrap().pattern.clear(), + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, name)).is_err(), "{name}: mutation {index} must be rejected"); + } + } +} + +#[test] +fn comparison_rejects_block_and_attribute_dependency_losses() { + use super::cases::{blocks, properties}; + let original = blocks::attributes("overrides").remove(0).drawing; + let mutations: [fn(&mut Drawing); 4] = [ + |d| { + d.blocks.remove("Z_Leaf"); + }, + |d| std::sync::Arc::make_mut(d.blocks.get_mut("Z_Leaf").unwrap()).base.x += 1.0, + |d| d.layer_mut("Survey_Ä").unwrap().color = cadcraft_color::Color::Index(5), + |d| { + let handle = d.model.iter().next().unwrap().handle; + d.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.attribs.reverse(); + }); + }, + ]; + model(&original, &original, "unchanged block and attributes"); + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "block dependency self-test")).is_err()); + } + let original = properties::complex_linetypes(false).remove(0).drawing; + let mut changed = original.clone(); + changed.text_styles.retain(|s| s.name != "Notes"); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "linetype style self-test")).is_err()); + let cyclic = blocks::nested(true).remove(0).drawing; + model(&cyclic, &cyclic, "cycles compare without expansion"); +} + +#[test] +fn angle_tolerance_does_not_hide_real_changes_or_nonfinite_values() { + angle(0.7, 0.7_f64.to_degrees().to_radians(), "self-test", "angle"); + for actual in [0.700001, f64::NAN, f64::INFINITY] { + assert!(std::panic::catch_unwind(|| angle(0.7, actual, "self-test", "angle")).is_err()); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/conformance.rs b/crates/io/tests/it/roundtrip/dwg/conformance.rs new file mode 100644 index 000000000..ff58850ce --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/conformance.rs @@ -0,0 +1,87 @@ +//! Independent bridge expectations, not an independent DWG codec. Writer checks inspect native +//! objects without CADCraft's reader; reader fixtures originate in native objects without our +//! DXF writer. acadrust still supplies the binary codec in both directions, so these tests do +//! not establish interoperability with another DWG implementation. All fixtures are synthetic. + +use super::super::cases; +use acadrust::types::Vector3; +use acadrust::{CadDocument, DwgReader, DwgWriter, DxfVersion, EntityType}; +use cadcraft_doc::{Circle, Common, EntityKind, Line, Text}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; +use std::io::Cursor; + +#[test] +fn writer_populates_native_geometry_without_our_reader() { + let source = cases::common_case("native-writer", Common::default()); + for version in Version::ALL { + let bytes = write_with(&source.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let entities: Vec<_> = native.entities().collect(); + assert_eq!(entities.len(), 3, "{version}"); + let EntityType::Line(line) = entities[0] else { panic!("expected native LINE") }; + assert_eq!(line.start, Vector3::new(-1.25, 2.5, 3.75), "{version}"); + assert_eq!(line.end, Vector3::new(8.125, -13.5, 21.25), "{version}"); + let EntityType::Circle(circle) = entities[1] else { panic!("expected native CIRCLE") }; + assert_eq!(circle.center, Vector3::new(-7.25, 11.5, 4.75), "{version}"); + assert_eq!(circle.radius, 3.125, "{version}"); + let EntityType::Text(text) = entities[2] else { panic!("expected native TEXT") }; + assert_eq!(text.value, "CAD Ø", "{version}"); + assert_eq!(text.insertion_point, Vector3::new(2.5, -3.75, 4.125), "{version}"); + assert_eq!(text.height, 2.25, "{version}"); + assert_eq!(text.width_factor, 1.0, "{version}"); + assert_eq!(text.rotation, 0.0, "{version}"); + } +} + +#[test] +#[ignore = "Confirmed native DWG mismatch: a pi/2 linetype rotation is stored as pi/2 degrees; DXF unit interpretation needs investigation"] +fn complex_linetype_rotation_is_correct_in_native_objects() { + // Both document APIs specify radians. Do not infer correctness from a DXF round trip: + // reader and writer can share a unit error. Public DXF references disagree about LTYPE + // group 50, so this asserts the end-to-end native angle, not a guessed DXF encoding. + for shape in [false, true] { + for case in cases::properties::complex_linetypes(shape) { + for version in Version::ALL { + let bytes = write_with(&case.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let line = native.line_types.iter().find(|l| l.name == "Dash_test").unwrap(); + let elements: Vec<_> = line.elements.iter().filter_map(|e| e.complex.as_ref()).collect(); + assert_eq!(elements.len(), 1, "{} {version}", case.name); + let rotation = elements[0].rotation; + assert!( + (rotation - std::f64::consts::FRAC_PI_2).abs() <= 8.0 * f64::EPSILON, + "{} {version}: native rotation {rotation} radians, expected pi/2", + case.name + ); + } + } + } +} + +#[test] +fn reader_accepts_native_fixtures_without_our_writer() { + for version in [DxfVersion::AC1015, DxfVersion::AC1018, DxfVersion::AC1021, DxfVersion::AC1024, DxfVersion::AC1027, DxfVersion::AC1032] { + let mut native = CadDocument::with_version(version); + let handles = [ + native.add_entity(EntityType::Line(acadrust::entities::Line::from_coords(1.25, -2.5, 3.75, -4.5, 5.25, -6.75))).unwrap(), + native.add_entity(EntityType::Circle(acadrust::entities::Circle::from_coords(7.25, -8.5, 9.75, 2.125))).unwrap(), + native.add_entity(EntityType::Text(acadrust::entities::Text::with_value("Independent Ø", Vector3::new(-3.25, 4.5, 1.75)))).unwrap(), + ]; + let expected = [ + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 }), + EntityKind::Text(Text { insert: Vec3::new(-3.25, 4.5, 1.75), height: 1.0, value: "Independent Ø".into(), ..cases::text() }), + ]; + let bytes = DwgWriter::write_to_vec(&native).unwrap(); + let back = read(&bytes, "native-fixture.dwg").unwrap(); + assert!(back.read_notes.is_empty(), "{:?}", back.read_notes); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.len(), expected.len()); + for ((entity, expected), handle) in back.model.iter().zip(&expected).zip(handles) { + assert_eq!(entity.handle.0, u64::from(handle), "{version:?}"); + assert_eq!(&entity.kind, expected, "{version:?}"); + assert_eq!(entity.common, Common::default(), "{version:?}"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/dimensions.rs b/crates/io/tests/it/roundtrip/dwg/dimensions.rs new file mode 100644 index 000000000..3599d43f5 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/dimensions.rs @@ -0,0 +1,210 @@ +//! DWG anonymous dimension-block suffixes are not persistent identities (#2257). Match blocks +//! through dimensions with preserved entity handles, then compare authored content and properties +//! strictly. Only anonymous *D names may change; missing references and merged blocks fail. +//! This verifier covers the model-space dimension fixtures, not arbitrary drawing graph remapping. +//! +//! Autodesk documents that anonymous block numbers may change between drawing sessions: +//! https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-AutoLISP/files/GUID-EB9BFDAA-487A-4586-A556-1F5F84BCF2F3.htm +//! ODA's DWG specification §20.4.52.1 shows a BLOCK_HEADER named *D without a suffix; +//! DIMENSION records reference their block by handle, not by its reconstructed name: +//! https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::Version; + +use super::super::{ + cases::{self, Case, complex}, + compare, +}; + +pub(super) fn check() { + for case in complex::dimensions().into_iter().chain([paired_dimensions(["*D42", "*D7"])]) { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + // DIMFXL has no native field before R2007 (same limit as other dimension-style cases). + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version_using(&case.name, &case.drawing, &expected, version, equivalent); + } + } +} + +// Two independent cache blocks with different authored geometry, plus an ordinary named block. +// Names are supplied explicitly so comparator controls do not learn expectations from a save. +fn paired_dimensions(names: [&str; 2]) -> Case { + let mut drawing = cases::drawing(); + for (index, (case, name)) in complex::dimensions().into_iter().zip(names).enumerate() { + let entity = case.drawing.model.iter().next().unwrap(); + let mut kind = entity.kind.clone(); + let EntityKind::Dimension(dim) = &mut kind else { panic!("fixture dimension") }; + dim.block = Some(name.into()); + drawing.add(&Space::Model, entity.common.clone(), kind).unwrap(); + let mut block = Block::new(name); + for entity in case.drawing.block("*D42").unwrap().entities.iter() { + block.entities.push(Entity { handle: drawing.new_handle(), ..entity.as_ref().clone() }); + } + // This circle is authored cache content, not something dimension regeneration produces. + block + .entities + .push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(5.0, 3.0, 0.0), radius: 0.25 + index as f64 }))); + drawing.blocks.insert(name.into(), Arc::new(block)); + } + let mut named = Block::new("UserPart"); + named.entities.push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 2.0, 0.0), radius: 2.0 }))); + drawing.blocks.insert(named.name.clone(), Arc::new(named)); + Case { name: "dimension/distinct-anonymous-caches-and-named-block".into(), drawing } +} + +fn anonymous_dimension_name(name: &str) -> bool { + name.strip_prefix("*D").is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_digit())) +} + +fn equivalent(expected: &Drawing, actual: &Drawing, context: &str) { + let mut names = BTreeMap::new(); + for entity in expected.model.iter() { + let EntityKind::Dimension(dim) = &entity.kind else { continue }; + let before = dim.block.as_deref().expect("fixture has a dimension block"); + let back = actual.entity(entity.handle).unwrap_or_else(|| panic!("{context}: missing dimension {}", entity.handle.hex())); + let EntityKind::Dimension(dim) = &back.kind else { panic!("{context}: dimension changed type") }; + let after = dim.block.as_deref().unwrap_or_else(|| panic!("{context}: dimension lost its block reference")); + let source = expected.block(before).expect("fixture block exists"); + let target = actual.block(after).unwrap_or_else(|| panic!("{context}: dangling dimension block reference {after}")); + if before != after { + assert!(source.anonymous && target.anonymous, "{context}: only anonymous dimension blocks may be renamed"); + assert!( + anonymous_dimension_name(before) && anonymous_dimension_name(after), + "{context}: unexpected block name mapping {before} -> {after}" + ); + } + if let Some(previous) = names.insert(before.to_string(), after.to_string()) { + assert_eq!(previous, after, "{context}: one dimension block split into different references"); + } + } + assert!(!names.is_empty(), "{context}: fixture must exercise dimension block references"); + assert_eq!(names.values().collect::>().len(), names.len(), "{context}: distinct dimension blocks were merged"); + assert_eq!(actual.blocks.len(), expected.blocks.len(), "{context}: block lost or extra cache generated"); + + // Adapt only the validated names, never content or properties learned from the output. + // Rebuild the map to handle renumberings that reuse another source block's old name. + let mut renamed = expected.clone(); + renamed.blocks.clear(); + for (name, block) in &expected.blocks { + let mapped = names.get(name).unwrap_or(name); + let mut block = block.clone(); + Arc::make_mut(&mut block).name = mapped.clone(); + assert!(renamed.blocks.insert(mapped.clone(), block).is_none(), "{context}: block name collision"); + } + for entity in expected.model.iter() { + if let EntityKind::Dimension(dim) = &entity.kind { + let mapped = names.get(dim.block.as_ref().expect("fixture block")).expect("mapped reference"); + renamed.model.modify(entity.handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = Some(mapped.clone()); + }); + } + } + compare::model(&renamed, actual, context); +} + +#[test] +fn comparison_accepts_only_anonymous_dimension_renumbering() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + equivalent(&original, &original, "unchanged"); + for names in [["*D1", "*D2"], ["*D7", "*D42"]] { + let renamed = paired_dimensions(names).drawing; + equivalent(&original, &renamed, "renumbered, including reused source names"); + // The general comparator must still reject renaming outside this DWG-specific check. + assert!(std::panic::catch_unwind(|| compare::model(&original, &renamed, "strict comparison")).is_err()); + } +} + +fn dimension_handles(d: &Drawing) -> Vec { + d.model.iter().filter(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).collect() +} + +fn set_reference(d: &mut Drawing, handle: Handle, name: Option<&str>) { + assert!(d.model.modify(handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = name.map(str::to_string); + })); +} + +#[test] +fn comparison_rejects_broken_references_content_and_block_properties() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + let renamed = paired_dimensions(["*D1", "*D2"]).drawing; + equivalent(&original, &renamed, "valid control"); + let mutations: [fn(&mut Drawing); 14] = [ + |d| set_reference(d, dimension_handles(d)[0], None), + |d| set_reference(d, dimension_handles(d)[0], Some("*D999")), + |d| set_reference(d, dimension_handles(d)[0], Some("*D2")), + |d| { + let hs = dimension_handles(d); + set_reference(d, hs[0], Some("*D2")); + set_reference(d, hs[1], Some("*D1")); + }, + |d| { + d.blocks.remove("*D1"); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).entities = Default::default(), + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.common.visible = false); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().last().unwrap().handle; + block.entities.modify(h, |e| { + let EntityKind::Circle(circle) = &mut e.kind else { panic!("fixture circle") }; + circle.radius += 1.0; + }); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.handle = Handle(0xFFFF)); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).base.x += 1.0, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).anonymous = false, + |d| { + d.blocks.insert("*D999".into(), Arc::new(Block::new("*D999"))); + }, + |d| { + let mut block = d.blocks.remove("UserPart").unwrap(); + Arc::make_mut(&mut block).name = "RenamedPart".into(); + d.blocks.insert("RenamedPart".into(), block); + }, + |d| { + let h = dimension_handles(d)[0]; + d.model.modify(h, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.p14.x += 1.0; + }); + }, + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = renamed.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "mutation")).is_err(), "mutation {index} must fail"); + } +} + +#[test] +fn comparison_rejects_changed_prefixes_and_named_dimension_cache_renaming() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + for name in ["*U1", "*D", "*Dcustom", "UserCache"] { + let changed = paired_dimensions([name, "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "wrong name kind")).is_err()); + } + let original = paired_dimensions(["AuthoredCache", "*D7"]).drawing; + let changed = paired_dimensions(["RenamedCache", "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "named cache")).is_err()); +} diff --git a/crates/io/tests/it/roundtrip/dwg/entities.rs b/crates/io/tests/it/roundtrip/dwg/entities.rs new file mode 100644 index 000000000..58f97d92f --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/entities.rs @@ -0,0 +1,171 @@ +//! DWG property families stay separate: a failing escape, plot-style or alignment regression +//! must not disable unrelated TEXT or Common tests. Pending cases assert the original model, +//! never today's lossy output. Only confirmed failures receive an explicit ignore reason. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, EntityKind, Transparency}; +use cadcraft_io::Version; + +use super::super::cases; +use super::{check, check_version}; + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style Review becomes ByLayer, including R2018"] +fn named_plot_style_is_preserved() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style is lost in combination with other properties"] +fn common_property_interactions_with_named_plot_style() { + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: literal Unicode/MIF escape text is decoded again, including R2018"] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); +} +#[test] +#[ignore = "Confirmed change: missing justified TEXT align_pt becomes (0,0,elevation), moving text"] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: ByBlock plot style becomes ByLayer"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: non-default extrusion becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} + +#[test] +fn text_points_share_one_elevation_in_dwg() { + // Public ODA DWG specification, TEXT (1): insertion and alignment points contain XY; + // a single elevation supplies both Z coordinates. This is a representational limit, not + // a bug or a comparison tolerance. DXF's original XYZ cases are still checked unchanged. + // https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + let cases = cases::noncoplanar_text_alignment(); + assert!(!cases.is_empty()); + for case in cases { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + let EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + let point = t.align_pt.as_mut().expect("second point"); + point.z = t.insert.z; + }); + } + for version in Version::ALL { + check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn r2000_explicit_color_and_transparency_downgrade() { + // R2000 has no true-color/alpha fields. An exact red RGB maps to ACI 1; neither a fixed + // alpha nor transparency ByBlock is representable. Expected values are authored here, + // not learnt from an initial save, and are checked again after the second save. + for transparency in [Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)] { + let case = cases::common_case( + &format!("legacy/true-red/transparency={transparency:?}"), + Common { color: Color::True(Rgb(255, 0, 0)), transparency, ..Common::default() }, + ); + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + e.common.color = Color::Index(1); + e.common.transparency = Transparency::ByLayer; + }); + } + check_version(&case.name, &case.drawing, &expected, Version::R2000); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/extended.rs b/crates/io/tests/it/roundtrip/dwg/extended.rs new file mode 100644 index 000000000..001712d28 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/extended.rs @@ -0,0 +1,395 @@ +//! Native DWG extensions to the shared preservation matrix. Version limits (native columns, +//! attributes, HELIX/MULTILEADER and table fields) are explicit, as are named-reference +//! resolution and legacy defaults. Other inputs use the full save-version domain. + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use super::check; +use cadcraft_io::Version; + +// DWG references a table record by handle, not by an independently spelled name. These +// explicit expected spellings test resolution, not a blanket case-folding comparator. +fn canonical_reference(kind: &str, name: &str) { + for case in properties::mixed_case_references(kind) { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| match kind { + "layer" => e.common.layer = name.into(), + "linetype" => e.common.linetype = name.into(), + "style" => { + let cadcraft_doc::EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + t.style = name.into(); + } + _ => panic!("unknown reference family"), + }); + } + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn legacy_block_metadata() { + // DWG BLOCK_HEADER gains insertion units and explodability in R2007 (ODA specification). + for case in blocks::metadata() { + let mut expected = case.drawing.clone(); + let block = std::sync::Arc::make_mut(expected.blocks.get_mut("Z_Leaf").unwrap()); + block.explodable = true; + block.units = 0; + for version in [Version::R2000, Version::R2004] { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + canonical_reference("layer", "Survey_Ä"); +} +#[test] +fn mixed_case_linetypes() { + canonical_reference("linetype", "Dash_test"); +} +#[test] +fn mixed_case_styles() { + canonical_reference("style", "Notes"); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +#[ignore = "Confirmed DWG loss: named layer plot style Review becomes Normal"] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); +} +#[test] +#[ignore = "Confirmed DWG loss: periodic closed fit spline reopens as an open spline"] +fn closed_fit_splines() { + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DWG loss: MTEXT columns disappear"] +fn mtext_columns() { + since_2007(geometry::mtext_columns()); +} +#[test] +fn leaders() { + with_dimension_styles(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed shared DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed shared DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +#[ignore = "Confirmed DWG loss: a space following an MTEXT caret is consumed"] +fn mtext_carets() { + check(geometry::mtext_carets()); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + since_2007(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + with_dimension_styles(complex::tolerances()); +} +#[test] +fn dimensions() { + super::dimensions::check(); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); +} +#[test] +#[ignore = "Confirmed DWG loss: current layer CLAYER becomes 0"] +fn header_current_layer() { + collection(collections::named_header_reference("CLAYER"), collections::assert_headers); +} +#[test] +fn header_text_style() { + collection(collections::named_header_reference("TEXTSTYLE"), collections::assert_headers); +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +#[ignore = "Confirmed DWG data loss: saving an unregistered entity succeeds but drops it instead of preserving it or returning an error"] +fn unknown_entities_are_preserved_or_explicitly_refused() { + for version in Version::ALL { + super::super::unknown::check("memory.dwg", version, false); + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} + +fn with_dimension_styles(cases: Vec) { + // Fixed-length extension lines (DIMFXL) were added in 2007. Older DWG records have no + // slot for the inactive default length, and the bridge supplies zero on read. + for case in cases { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/mod.rs b/crates/io/tests/it/roundtrip/dwg/mod.rs new file mode 100644 index 000000000..6f9c90e1d --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/mod.rs @@ -0,0 +1,75 @@ +//! Native DWG Drawing → bytes → Drawing preservation, using the same cases and assertions as +//! DXF. Every representable case runs at all six save versions and through two saves. R2000's +//! lack of true color/transparency is a format limit, tested separately with explicit expected +//! downgrades; it is not a normalization of modern output. TEXT's single elevation is also +//! checked separately for non-coplanar inputs. Extended cases also specify handle-based name +//! resolution, anonymous dimension-block names and legacy block/dimension-style limits +//! explicitly. Other differences fail. +//! Native-object conformance checks bypass one bridge direction, not the underlying codec. +//! +//! Run `cargo test -p cadcraft-io --test it roundtrip::dwg`; add `-- --ignored` to reproduce +//! pending defects. No DWG bytes are imported from another application or kept as fixtures. + +mod conformance; +mod dimensions; +mod entities; +mod extended; + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, EntityKind, Transparency}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases, compare}; + +fn check(cases: Vec) { + for case in cases { + assert!( + !case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z))), + "{}: non-coplanar TEXT belongs in the explicit single-elevation test", + case.name + ); + let mut tested = 0; + for version in Version::ALL { + if version == Version::R2000 && needs_color_or_transparency(&case.drawing) { + continue; + } + check_version(&case.name, &case.drawing, &case.drawing, version); + tested += 1; + } + assert!(tested > 0, "{}: the version domain must not exclude a whole case", case.name); + } +} + +fn needs_color_or_transparency(d: &Drawing) -> bool { + d.model.iter().any(|e| matches!(e.common.color, Color::True(_)) || e.common.transparency != Transparency::ByLayer) + || d.layers.iter().any(|l| matches!(l.color, Color::True(_)) || l.transparency != 0) +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + check_version_using(case, original, expected, version, |a, b, context| { + compare::model(a, b, context); + extra(a, b, context); + }); +} + +fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, version: Version, verify: impl Fn(&Drawing, &Drawing, &str)) { + let options = SaveOptions { version: Some(version), binary: false }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · DWG {version} · pass={pass}"); + let bytes = write_with(¤t, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + verify(expected, &back, &context); + if pass == 2 { + verify(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/attributes.rs b/crates/io/tests/it/roundtrip/dxf/attributes.rs new file mode 100644 index 000000000..0962f7bc9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/attributes.rs @@ -0,0 +1,163 @@ +//! Check multiline ATTRIB values, embedded geometry and legacy placement independently. +//! Unchanged MTEXT rendering does not excuse corrupted attribute values or fallback TEXT +//! justification (#2255). Full field preservation remains in `extended::multiline_attributes`; +//! the legacy-placement test must reach its geometry assertion even while that check fails. +//! ODA's DWG specification §20.4.4 maps the R2018+ embedded object to DXF group 101. + +use super::super::cases::blocks; +use cadcraft_doc::{Attrib, Drawing, EntityKind, Space}; +use cadcraft_io::{SaveOptions, Version}; +use cadcraft_render::{DisplayList, Options, build}; + +const EDITED_VALUE: &str = "edited {value}\nthird line"; +const EDITED_CONTENTS: &str = "edited \\{value\\}\\Pthird line"; + +/// The fixture's primary attribute, not its unrelated single-line control or its ATTDEF. +fn primary(drawing: &Drawing) -> &Attrib { + let mut attributes = drawing + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.attribs.iter()), + _ => None, + }) + .flatten() + .filter(|a| a.tag == "ID"); + let attribute = attributes.next().expect("primary ATTRIB must survive"); + assert!(attributes.next().is_none(), "primary ATTRIB must not be duplicated"); + attribute +} + +fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) { + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + let mut changed = 0; + for handle in handles { + drawing.model.modify(handle, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + for a in i.attribs.iter_mut().filter(|a| a.tag == "ID") { + edit(a); + changed += 1; + } + } + }); + } + assert_eq!(changed, 1, "edit exactly the primary ATTRIB"); +} + +fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing { + let options = SaveOptions { version: Some(version), binary }; + let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + back +} + +fn geometry(drawing: &Drawing) -> DisplayList { + build(drawing, &Space::Model, &Options::default()) +} + +fn same_geometry(expected: &DisplayList, actual: &DisplayList, context: &str) { + assert!(!expected.prims.is_empty(), "{context}: nonempty fixture"); + assert_eq!(expected.prims.len(), actual.prims.len(), "{context}: primitive count"); + for (a, b) in expected.prims.iter().zip(&actual.prims) { + assert_eq!(a.kind, b.kind, "{context}: primitive kind"); + assert_eq!(expected.points(a), actual.points(b), "{context}: world-space geometry"); + } +} + +/// Check saved data, not only the effective text drawn by `multiline_text()`. That method can +/// hide a corrupt value/read_as pair or replace stale embedded contents from the plain value. +fn saved_text(drawing: &Drawing, value: &str, contents: &str, context: &str) { + let a = primary(drawing); + assert_eq!(a.text.value, value, "{context}: attribute value"); + let m = a.multiline.as_ref().expect("embedded MTEXT must survive"); + assert_eq!(m.read_as, value, "{context}: read_as"); + assert_eq!(m.mtext.contents, contents, "{context}: raw embedded contents"); +} + +#[test] +fn multiline_geometry_and_value_edits_survive_two_saves() { + for case in blocks::multiline_attributes() { + let expected = geometry(&case.drawing); + let mut edited = case.drawing.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + let edited_expected = geometry(&edited); + assert_ne!(expected.verts, edited_expected.verts, "value edit must actually change the drawn text"); + for binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{} · R2018 · binary={binary} · pass={pass}", case.name); + current = reopen(¤t, Version::R2018, binary, &context); + saved_text(¤t, "first\nsecond", "first\\Psecond", &context); + same_geometry(&expected, &geometry(¤t), &context); + let mut edited = current.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + same_geometry(&edited_expected, &geometry(&edited), &format!("{context} · edit after reopening")); + let context = format!("{context} · save after editing"); + let back = reopen(&edited, Version::R2018, binary, &context); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, &context); + same_geometry(&edited_expected, &geometry(&back), &context); + } + } + } +} + +#[test] +fn saved_text_checks_reject_corruption_hidden_by_rendering() { + let mut source = blocks::multiline_attributes().remove(0).drawing; + edit_primary(&mut source, |a| a.text.value = EDITED_VALUE.into()); + let back = reopen(&source, Version::R2018, true, "mutation control"); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, "mutation control"); + for field in ["value", "read_as", "contents", "value-and-read_as"] { + let mut corrupt = back.clone(); + edit_primary(&mut corrupt, |a| { + let m = a.multiline.as_mut().unwrap(); + match field { + "value" => a.text.value = "CORRUPTED VALUE".into(), + "read_as" => m.read_as = "CORRUPTED VALUE".into(), + "contents" => m.mtext.contents = "CORRUPTED CONTENTS".into(), + "value-and-read_as" => { + a.text.value = "CORRUPTED VALUE".into(); + m.read_as.clone_from(&a.text.value); + } + _ => unreachable!(), + } + }); + if field == "value-and-read_as" { + same_geometry(&geometry(&back), &geometry(&corrupt), "corruption invisible to geometry"); + } + assert!(std::panic::catch_unwind(|| saved_text(&corrupt, EDITED_VALUE, EDITED_CONTENTS, field)).is_err(), "must reject {field}"); + } +} + +#[test] +#[ignore = "Confirmed DXF placement loss (#2255): R2018 save changes subsequent legacy ATTRIB placement; existing reader fix #1942"] +fn legacy_placement_is_independent_of_an_intermediate_modern_save() { + for case in blocks::multiline_attributes() { + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let legacy_context = format!("{} · legacy={version} · binary={binary}", case.name); + let direct = reopen(&case.drawing, version, binary, &legacy_context); + let a = primary(&direct); + assert!(a.multiline.is_none(), "{legacy_context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{legacy_context}: fallback value"); + let expected = geometry(&direct); + for modern_binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{legacy_context} · intermediate=R2018 · binary={modern_binary} · pass={pass}"); + current = reopen(¤t, Version::R2018, modern_binary, &context); + let indirect = reopen(¤t, version, binary, &context); + let a = primary(&indirect); + assert!(a.multiline.is_none(), "{context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{context}: fallback value"); + same_geometry(&expected, &geometry(&indirect), &context); + } + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/common.rs b/crates/io/tests/it/roundtrip/dxf/common.rs new file mode 100644 index 000000000..6e40977c9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/common.rs @@ -0,0 +1,197 @@ +//! Independent input and output contracts for common LINE/CIRCLE/TEXT properties. Read fixtures +//! are handwritten DXF, never writer output; write expectations are literal groups, never a +//! reread Drawing. Absence, explicit zero and inheritance are distinct. Pending tests localize +//! the existing extrusion and ByBlock plot-style losses without accepting them as correct. +//! References: https://ezdxf.readthedocs.io/en/stable/dxfentities/dxfgfx.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/true_color.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/transparency.html + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn fixture(properties: &str) -> Drawing { + let records = [ + ("LINE", "AcDbLine", "10\n1\n20\n2\n30\n3\n11\n4\n21\n5\n31\n6\n"), + ("CIRCLE", "AcDbCircle", "10\n7\n20\n8\n30\n9\n40\n2.5\n"), + ("TEXT", "AcDbText", "10\n3\n20\n4\n40\n2.5\n1\nfixture\n"), + ]; + let mut bytes = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n"); + for (kind, subclass, geometry) in records { + bytes.push_str(&format!("0\n{kind}\n100\nAcDbEntity\n{properties}100\n{subclass}\n{geometry}")); + } + bytes.push_str("0\nENDSEC\n0\nEOF\n"); + read(bytes.as_bytes(), "fixture.dxf").unwrap() +} + +fn assert_read(properties: &str, expected: &Common) { + let drawing = fixture(properties); + assert_eq!(drawing.model.len(), 3); + assert!(drawing.skipped_records.is_empty()); + for entity in drawing.model.iter() { + assert_eq!(&entity.common, expected, "{} with groups {properties:?}", entity.kind.type_name()); + } +} + +fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec)> { + let mut drawing = super::conformance::model(); + if drawing.layer(&common.layer).is_none() { + drawing.layers.push(Layer::new(&common.layer)); + } + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + drawing.model.modify(handle, |e| e.common = common.clone()); + } + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::>(), ["LINE", "CIRCLE", "TEXT"]); + entities +} + +fn assert_write(common: Common, present: &[Tag], absent: &[i32]) { + for version in Version::ALL { + for binary in [false, true] { + for (kind, groups) in output(&common, version, binary) { + let context = format!("{version} binary={binary} {kind} {common:?}"); + for tag in present { + assert_eq!(groups.iter().filter(|t| t.code == tag.code).collect::>(), [tag], "{context}: group {}", tag.code); + } + for code in absent { + assert!(!groups.iter().any(|t| t.code == *code), "{context}: group {code} should be omitted"); + } + } + } + } +} + +#[test] +fn missing_optional_groups_and_explicit_defaults() { + assert_read("", &Common::default()); + assert_read("8\n0\n6\nByLayer\n62\n256\n370\n-1\n48\n1\n60\n0\n39\n0\n210\n0\n220\n0\n230\n1\n", &Common::default()); + // Omission is this writer's convention, not a requirement that other DXF writers omit them. + assert_write(Common::default(), &[Tag::s(8, "0")], &[6, 62, 370, 48, 60, 39, 420, 440, 210, 220, 230]); +} + +#[test] +fn layer_linetype_scale_visibility_and_thickness_have_independent_groups() { + let common = + Common { layer: "Fixture".into(), linetype: "Continuous".into(), ltscale: 2.75, visible: false, thickness: -1.25, ..Common::default() }; + assert_read("8\nFixture\n6\nContinuous\n48\n2.75\n60\n1\n39\n-1.25\n", &common); + assert_write(common, &[Tag::s(8, "Fixture"), Tag::s(6, "Continuous"), Tag::f(48, 2.75), Tag::i(60, 1), Tag::f(39, -1.25)], &[62, 420, 440]); + assert_read("6\nByBlock\n", &Common { linetype: "ByBlock".into(), ..Common::default() }); + assert_write(Common { linetype: "ByBlock".into(), ..Common::default() }, &[Tag::s(6, "ByBlock")], &[]); +} + +#[test] +fn lineweight_distinguishes_logical_modes_and_explicit_zero() { + for (value, lineweight) in [ + (-1, Lineweight::ByLayer), + (-2, Lineweight::ByBlock), + (-3, Lineweight::Default), + (0, Lineweight::Mm100(0)), + (53, Lineweight::Mm100(53)), + (211, Lineweight::Mm100(211)), + ] { + let common = Common { lineweight, ..Common::default() }; + assert_read(&format!("370\n{value}\n"), &common); + if value == -1 { + assert_write(common, &[], &[370]); + } else { + assert_write(common, &[Tag::i(370, value)], &[]); + } + } +} + +#[test] +fn aci_color_distinguishes_inheritance_and_explicit_color() { + for (value, color) in + [(256, Color::ByLayer), (0, Color::ByBlock)].into_iter().chain((1..=255).map(|index| (i64::from(index), Color::Index(index)))) + { + let common = Common { color, ..Common::default() }; + assert_read(&format!("62\n{value}\n"), &common); + if value == 256 { + assert_write(common, &[], &[62, 420]); + } else { + assert_write(common, &[Tag::i(62, value)], &[420]); + } + } +} + +#[test] +fn true_color_overrides_aci_in_either_tag_order_including_black() { + // 0x12_34_56 = 1,193,046. The black value 0 must not be mistaken for an absent group. + for (value, rgb) in [(1_193_046, Rgb(0x12, 0x34, 0x56)), (0, Rgb(0, 0, 0))] { + let common = Common { color: Color::True(rgb), ..Common::default() }; + for aci in [0, 1, 256] { + assert_read(&format!("62\n{aci}\n420\n{value}\n"), &common); + assert_read(&format!("420\n{value}\n62\n{aci}\n"), &common); + } + assert_write(common, &[Tag::i(420, value)], &[]); + } +} + +#[test] +fn transparency_distinguishes_inheritance_and_explicit_opacity() { + assert_read("", &Common::default()); + assert_write(Common::default(), &[], &[440]); + // High byte: 01 = ByBlock, 02 = explicit alpha. Low byte: opacity, not transparency. + // Independent integer oracle: floor(255 * (1 - p/100)) = 255 - ceil(255*p/100). + // No production alpha conversion is used. Check every supported integer percentage. + for (raw, transparency) in std::iter::once((0x0100_0000, Transparency::ByBlock)).chain((0..=90).map(|percent| { + let opacity = 255 - (255 * i64::from(percent) + 99) / 100; + (0x0200_0000 + opacity, Transparency::Percent(percent)) + })) { + let common = Common { transparency, ..Common::default() }; + assert_read(&format!("440\n{raw}\n"), &common); + assert_write(common, &[Tag::i(440, raw)], &[]); + } + // Another writer may round up instead; both neighboring alpha values map to the same + // integer percentage. This is a read expectation, not normalization of writer output. + assert_read("440\n33554560\n", &Common { transparency: Transparency::Percent(50), ..Common::default() }); + assert_read("440\n33554458\n", &Common { transparency: Transparency::Percent(90), ..Common::default() }); +} + +#[test] +fn reads_line_extrusion_without_changing_world_space_endpoints() { + for (groups, expected) in [("210\n0\n220\n0\n230\n-1\n", Vec3::new(0.0, 0.0, -1.0)), ("220\n1\n230\n0\n", Vec3::new(0.0, 1.0, 0.0))] { + let drawing = fixture(groups); + let line = drawing.model.iter().next().unwrap(); + assert_eq!(line.common.extrusion, expected); + let EntityKind::Line(geometry) = &line.kind else { panic!("expected LINE") }; + assert_eq!(geometry.a, Vec3::new(1.0, 2.0, 3.0)); + assert_eq!(geometry.b, Vec3::new(4.0, 5.0, 6.0)); + } +} + +#[test] +#[ignore = "Known writer loss: a non-default extrusion is omitted; the independent LINE read test passes"] +fn writes_nondefault_extrusion_groups() { + // X/Y may be omitted at zero, but Z=-1 must not disappear into the default Z=1. + assert_write(Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }, &[Tag::f(230, -1.0)], &[]); +} + +#[test] +#[ignore = "Known writer loss: ByLayer and ByBlock plot styles produce identical files"] +fn writer_must_distinguish_bylayer_from_byblock_plot_style() { + // The available group-380 HEADER enum is not sufficient evidence for a universal entity + // encoding; do not prescribe an unverified tag here. Identical complete outputs prove the + // loss is already in the writer, independently of any reader or representation choice. + let by_layer = super::conformance::model(); + let mut by_block = by_layer.clone(); + let handles: Vec<_> = by_block.model.iter().map(|e| e.handle).collect(); + for handle in handles { + by_block.model.modify(handle, |e| e.common.plot_style = "ByBlock".into()); + } + for version in Version::ALL { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let a = write_with(&by_layer, "memory.dxf", &options).unwrap(); + let b = write_with(&by_block, "memory.dxf", &options).unwrap(); + assert!(a != b, "{version} binary={binary}: plot style is not encoded anywhere in the file"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/conformance.rs b/crates/io/tests/it/roundtrip/dxf/conformance.rs new file mode 100644 index 000000000..3f036be2c --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/conformance.rs @@ -0,0 +1,185 @@ +//! Independent synthetic entity records prevent matching reader/writer mistakes from passing +//! the round-trip suite. Only public DXF group meanings are used; no reference-app files or assets. +//! Entity semantics: https://ezdxf.readthedocs.io/en/stable/dxfentities/text.html +//! and https://ezdxf.readthedocs.io/en/stable/dxfentities/line.html + +use cadcraft_color::Color; +use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::compare; + +const ENTITIES: &[u8] = b"0\nSECTION\n2\nENTITIES\n\ +0\nLINE\n5\n100\n8\n0\n62\n3\n60\n1\n39\n-2.5\n10\n1.25\n20\n-2.5\n30\n3.75\n11\n-4.5\n21\n5.25\n31\n-6.75\n\ +0\nCIRCLE\n5\n101\n8\n0\n10\n7.25\n20\n-8.5\n30\n9.75\n40\n2.125\n\ +0\nTEXT\n5\n102\n8\n0\n10\n-3.25\n20\n4.5\n30\n1.75\n40\n2.5\n1\nfixture text\n50\n30\n41\n1.25\n51\n-15\n7\nStandard\n\ +0\nENDSEC\n0\nEOF\n"; + +pub(super) fn model() -> Drawing { + let mut d = Drawing::new_metric(); + d.add( + &Space::Model, + Common { color: Color::Index(3), visible: false, thickness: -2.5, ..Common::default() }, + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + ) + .unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 })).unwrap(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Text(Text { + insert: Vec3::new(-3.25, 4.5, 1.75), + align_pt: None, + height: 2.5, + value: "fixture text".into(), + rotation: 30_f64.to_radians(), + width_factor: 1.25, + oblique: -15_f64.to_radians(), + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }), + ) + .unwrap(); + d +} + +#[test] +fn reads_independent_line_circle_and_text_records() { + let back = read(ENTITIES, "fixture.dxf").unwrap(); + compare::model(&model(), &back, "independent entity fixture"); +} + +#[test] +fn writes_standard_entity_groups_not_just_self_readable_data() { + let expected = [ + ( + "LINE", + vec![ + Tag::s(5, "100"), + Tag::i(62, 3), + Tag::i(60, 1), + Tag::f(39, -2.5), + Tag::f(10, 1.25), + Tag::f(20, -2.5), + Tag::f(30, 3.75), + Tag::f(11, -4.5), + Tag::f(21, 5.25), + Tag::f(31, -6.75), + ], + ), + ("CIRCLE", vec![Tag::s(5, "101"), Tag::f(10, 7.25), Tag::f(20, -8.5), Tag::f(30, 9.75), Tag::f(40, 2.125)]), + ( + "TEXT", + vec![ + Tag::s(5, "102"), + Tag::f(10, -3.25), + Tag::f(20, 4.5), + Tag::f(30, 1.75), + Tag::f(40, 2.5), + Tag::s(1, "fixture text"), + Tag::f(41, 1.25), + Tag::s(7, "Standard"), + ], + ), + ]; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), expected.len()); + for ((kind, groups), (expected_kind, expected_groups)) in entities.iter().zip(&expected) { + let context = format!("{version} binary={binary} {expected_kind}"); + assert_eq!(kind, expected_kind, "{context}"); + for tag in expected_groups { + let values: Vec<_> = groups.iter().filter(|t| t.code == tag.code).collect(); + assert_eq!(values, [tag], "{context}: group {}", tag.code); + } + if kind == "TEXT" { + for (code, degrees) in [(50, 30.0), (51, -15.0)] { + let values: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(values.len(), 1, "{context}: group {code}"); + assert!((values[0].f64() - degrees).abs() < 1e-13, "{context}: group {code} is in degrees"); + } + } + } + } + } +} + +#[test] +fn reads_text_alignment_codes_independently_of_the_writer() { + let horizontal = [(0, HAlign::Left), (1, HAlign::Center), (2, HAlign::Right), (3, HAlign::Aligned), (4, HAlign::Middle), (5, HAlign::Fit)]; + let vertical = [(0, VAlign::Baseline), (1, VAlign::Bottom), (2, VAlign::Middle), (3, VAlign::Top)]; + for (hcode, halign) in horizontal { + for (vcode, valign) in vertical { + if hcode >= 3 && vcode != 0 { + continue; + } + let bytes = format!( + "0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n30\n5\n40\n2.5\n1\nA\n72\n{hcode}\n73\n{vcode}\n11\n6\n21\n7\n31\n8\n0\nENDSEC\n0\nEOF\n" + ); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!((text.halign, text.valign), (halign, valign), "72={hcode}, 73={vcode}"); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 5.0)); + let expected_point = if hcode == 0 && vcode == 0 { None } else { Some(Vec3::new(6.0, 7.0, 8.0)) }; + assert_eq!(text.align_pt, expected_point, "72={hcode}, 73={vcode}"); + } + } +} + +// Odd values above 2^53, above i64::MAX and near u64::MAX expose floating-point coercion, +// signed conversion and truncation. Literal hex and numeric values are independent expectations. +const LARGE_HANDLES: [(u64, &str); 3] = + [(9_007_199_254_740_993, "20000000000001"), (9_223_372_036_854_775_809, "8000000000000001"), (18_446_744_073_709_486_081, "FFFFFFFFFFFF0001")]; + +#[test] +fn reads_large_literal_hex_handles_without_numeric_coercion() { + let mut fixture = String::from("0\nSECTION\n2\nENTITIES\n"); + for (_, hex) in LARGE_HANDLES { + fixture.push_str(&format!("0\nLINE\n5\n{hex}\n8\n0\n10\n0\n20\n0\n11\n1\n21\n2\n")); + } + fixture.push_str("0\nENDSEC\n0\nEOF\n"); + let back = read(fixture.as_bytes(), "fixture.dxf").unwrap(); + assert!(back.read_notes.is_empty()); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.iter().map(|e| e.handle.0).collect::>(), LARGE_HANDLES.map(|(numeric, _)| numeric)); +} + +#[test] +fn writes_large_handles_as_exact_hex_groups() { + let mut drawing = Drawing::new_metric(); + for (numeric, _) in LARGE_HANDLES { + drawing.model.push(Entity::new(Handle(numeric), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 0.0) }))); + } + drawing.handseed = 0xffff_ffff_ffff_1000; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), LARGE_HANDLES.len()); + for ((kind, groups), (_, hex)) in entities.iter().zip(LARGE_HANDLES) { + assert_eq!(kind, "LINE"); + assert_eq!(groups.iter().filter(|t| t.code == 5).collect::>(), [&Tag::s(5, hex)], "{version} binary={binary} handle={hex}"); + } + } + } +} + +#[test] +fn left_baseline_text_ignores_the_unused_second_alignment_point() { + // DXF TEXT groups 11/21/31 are meaningful only when 72 or 73 is nonzero. The model + // intentionally represents the unused point as None, not Some(Vec3::ZERO). + let bytes = b"0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n40\n2.5\n1\nA\n72\n0\n73\n0\n11\n0\n21\n0\n31\n0\n0\nENDSEC\n0\nEOF\n"; + let back = read(bytes, "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!(text.align_pt, None); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 0.0)); +} diff --git a/crates/io/tests/it/roundtrip/dxf/entities.rs b/crates/io/tests/it/roundtrip/dxf/entities.rs new file mode 100644 index 000000000..5816d5017 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/entities.rs @@ -0,0 +1,121 @@ +//! DXF runs every shared input family in both encodings and all writable versions. Keep known +//! failures isolated from other property sweeps; the comparison contract is never weakened. + +use super::super::cases; + +fn check(cases: Vec) { + for case in cases { + super::check(&case.name, &case.drawing); + } +} + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +fn plot_styles() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); + check(cases::noncoplanar_text_alignment()); +} +#[test] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} + +#[test] +#[ignore = "Known writer loss: ByBlock plot style becomes ByLayer on the first R2000 ASCII save"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} + +#[test] +#[ignore = "Known writer loss: groups 210/220/230 are omitted; a LINE's normal becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} diff --git a/crates/io/tests/it/roundtrip/dxf/extended.rs b/crates/io/tests/it/roundtrip/dxf/extended.rs new file mode 100644 index 000000000..4e8415337 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/extended.rs @@ -0,0 +1,334 @@ +//! Extended DXF property families. Pending losses stay separate from passing families; +//! version domains are explicit for Lock position, HELIX/MULTILEADER (2007) and the native +//! multiline-attribute representation (2018). + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use cadcraft_io::Version; + +fn check(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + super::check(&case.name, &case.drawing); + } +} +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + check(properties::mixed_case_references("layer")); +} +#[test] +fn mixed_case_linetypes() { + check(properties::mixed_case_references("linetype")); +} +#[test] +fn mixed_case_styles() { + check(properties::mixed_case_references("style")); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); + check(geometry::mtext_carets()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DXF loss: MTEXT column group 48 is also read as Common.ltscale"] +fn mtext_columns() { + check(geometry::mtext_columns()); +} +#[test] +fn leaders() { + check(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + check(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +#[ignore = "Confirmed DXF read loss (#2255): multiline header changes Left to Center and synthesizes align_pt; existing fix #1942"] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + check(complex::tolerances()); +} +#[test] +fn dimensions() { + check(complex::dimensions()); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); + for key in ["CLAYER", "TEXTSTYLE"] { + collection(collections::named_header_reference(key), collections::assert_headers); + } +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +fn unknown_entity_payload_and_references() { + for version in Version::ALL { + for binary in [false, true] { + super::super::unknown::check("memory.dxf", version, binary); + } + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} diff --git a/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs new file mode 100644 index 000000000..b447638eb --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs @@ -0,0 +1,138 @@ +//! Independent synthetic DXF records for the extended geometry slice. Literal group streams +//! specify both reader input and writer expectations; neither side obtains its oracle from +//! the other. Group meanings: https://ezdxf.readthedocs.io/en/stable/dxfentities/index.html + +use super::super::{cases, compare}; +use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +struct Fixture { + wire_type: &'static str, + kind: EntityKind, + groups: &'static str, + output: Vec, +} + +fn fixtures() -> Vec { + vec![ + Fixture { + wire_type: "ARC", + kind: EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius: 2.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.5\n50\n0\n51\n90\n", + output: vec![Tag::f(10, 1.25), Tag::f(20, -2.5), Tag::f(30, 3.75), Tag::f(40, 2.5), Tag::f(50, 0.0), Tag::f(51, 90.0)], + }, + Fixture { + wire_type: "ELLIPSE", + kind: EntityKind::Ellipse(Ellipse { + center: Vec3::new(1.0, 2.0, 3.0), + major: Vec3::new(3.0, 4.0, 0.0), + ratio: 0.5, + start: 0.0, + end: std::f64::consts::TAU, + }), + groups: "10\n1\n20\n2\n30\n3\n11\n3\n21\n4\n31\n0\n40\n0.5\n41\n0\n42\n6.283185307179586\n", + output: vec![Tag::f(11, 3.0), Tag::f(21, 4.0), Tag::f(31, 0.0), Tag::f(40, 0.5), Tag::f(41, 0.0), Tag::f(42, std::f64::consts::TAU)], + }, + Fixture { + wire_type: "LWPOLYLINE", + kind: EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { p: Vec2::new(1.25, -2.5), bulge: 0.5, start_width: 0.125, end_width: 0.75 }, + PolyVertex::new(Vec2::new(5.0, 3.0)), + ], + closed: true, + plinegen: true, + const_width: 0.0, + elevation: -3.75, + }), + groups: "90\n2\n70\n129\n38\n-3.75\n10\n1.25\n20\n-2.5\n40\n0.125\n41\n0.75\n42\n0.5\n10\n5\n20\n3\n", + output: vec![ + Tag::i(90, 2), + Tag::i(70, 129), + Tag::f(38, -3.75), + Tag::f(10, 1.25), + Tag::f(10, 5.0), + Tag::f(20, -2.5), + Tag::f(20, 3.0), + Tag::f(40, 0.125), + Tag::f(41, 0.75), + Tag::f(42, 0.5), + ], + }, + Fixture { + wire_type: "MTEXT", + kind: EntityKind::MText(MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 5, + rotation: std::f64::consts::FRAC_PI_2, + style: "Standard".into(), + contents: "first\\Psecond".into(), + line_spacing: 1.75, + line_spacing_exact: true, + columns: None, + }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.25\n41\n12.5\n71\n5\n50\n90\n7\nStandard\n1\nfirst\\Psecond\n73\n2\n44\n1.75\n", + output: vec![ + Tag::f(40, 2.25), + Tag::f(41, 12.5), + Tag::i(71, 5), + Tag::f(50, 90.0), + Tag::s(7, "Standard"), + Tag::s(1, "first\\Psecond"), + Tag::i(73, 2), + Tag::f(44, 1.75), + ], + }, + Fixture { + wire_type: "POINT", + kind: EntityKind::Point(Point { p: Vec3::new(1.0, 2.0, 3.0), angle: 0.0 }), + groups: "10\n1\n20\n2\n30\n3\n", + output: vec![Tag::f(10, 1.0), Tag::f(20, 2.0), Tag::f(30, 3.0)], + }, + Fixture { + wire_type: "RAY", + kind: EntityKind::Ray(RayLine { base: Vec3::new(1.0, 2.0, 3.0), dir: Vec3::new(0.0, 1.0, 0.0) }), + groups: "10\n1\n20\n2\n30\n3\n11\n0\n21\n1\n31\n0\n", + output: vec![Tag::f(11, 0.0), Tag::f(21, 1.0), Tag::f(31, 0.0)], + }, + ] +} + +#[test] +fn reads_extended_entities_from_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let bytes = format!("0\nSECTION\n2\nENTITIES\n0\n{name}\n5\n100\n{}0\nENDSEC\n0\nEOF\n", fixture.groups); + let expected = cases::entity_case(name, Common::default(), fixture.kind.clone()); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + compare::model(&expected.drawing, &back, name); + } +} + +#[test] +fn writes_extended_entities_with_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let source = cases::entity_case(name, Common::default(), fixture.kind.clone()); + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(records.len(), 1); + let (kind, groups) = &records[0]; + assert_eq!(kind, name); + for code in fixture.output.iter().map(|t| t.code) { + let expected: Vec<_> = fixture.output.iter().filter(|t| t.code == code).collect(); + let actual: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(actual, expected, "{name} {version} binary={binary} group={code}"); + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/mod.rs b/crates/io/tests/it/roundtrip/dxf/mod.rs new file mode 100644 index 000000000..929c1a483 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/mod.rs @@ -0,0 +1,56 @@ +//! DXF preservation through public Drawing → bytes → Drawing APIs, without disk or UI. +//! Covers modeled entity families, stored block graphs, references and selected drawing +//! collections, not whole-drawing/session equivalence. Each case runs in ASCII and binary, +//! twice, at every representable save version; expectations never come from writer output. +//! WIPEOUT's explicit closing marker is an authored format expectation, not a new corner. +//! +//! Run: `cargo test -p cadcraft-io --test it roundtrip::dxf`. +//! Add `-- --ignored` to reproduce the pending preservation cases. Those tests +//! assert desired preservation, not today's wrong result; remove `ignore` when fixing them. +//! Independent fixtures cover the core entity groups; deep compound-entity interoperability +//! still needs separate format checks beyond the self-round-trip matrix. + +mod attributes; +mod common; +mod conformance; +mod entities; +mod extended; +mod geometry_groups; +mod table_flags; + +use super::compare; +use cadcraft_doc::Drawing; +use cadcraft_io::{SaveOptions, Version, read, save_version, write_with}; + +fn check(case: &str, original: &Drawing) { + for version in Version::ALL { + check_version(case, original, original, version); + } +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · {version} · binary={binary} · pass={pass}"); + let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + compare::model(expected, &back, &context); + extra(expected, &back, &context); + if pass == 2 { + compare::model(¤t, &back, &format!("{context} · resave stability")); + extra(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/table_flags.rs b/crates/io/tests/it/roundtrip/dxf/table_flags.rs new file mode 100644 index 000000000..cefc28bb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/table_flags.rs @@ -0,0 +1,128 @@ +//! Independent standard LAYER/STYLE flag contracts. Literal wire-to-field mappings cover the +//! small modeled flag domains, including off-vs-frozen and vertical-vs-mirrored distinctions. +//! Reader fixtures are handwritten records; writer checks inspect tags, not our Drawing reader. +//! Annotative STYLE xdata is covered separately by the round-trip tests, not this native-bit test. +//! References: Autodesk DXF Reference, LAYER and STYLE symbol table entries. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, Layer, TextStyle}; +use cadcraft_dxf::{Tag, Value, records, sections}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn layers() -> Vec<(Layer, i64)> { + let mut cases = Vec::new(); + // (group 70, frozen, frozen in new viewports, locked). Off is the sign of 62, + // not another flag; plot is group 290 and must not accidentally change any of these. + for (flags, frozen, vp_freeze_new, locked) in [ + (0, false, false, false), + (1, true, false, false), + (2, false, true, false), + (3, true, true, false), + (4, false, false, true), + (5, true, false, true), + (6, false, true, true), + (7, true, true, true), + ] { + for on in [false, true] { + for plot in [false, true] { + cases.push(( + Layer { name: "Flags".into(), color: Color::Index(3), frozen, vp_freeze_new, locked, on, plot, ..Layer::default() }, + flags, + )); + } + } + } + cases +} + +fn styles() -> Vec<(TextStyle, i64, i64)> { + let mut cases = Vec::new(); + for (generation, backwards, upside_down) in [(0, false, false), (2, true, false), (4, false, true), (6, true, true)] { + for (flags, vertical) in [(0, false), (4, true)] { + cases.push(( + TextStyle { name: "Flags".into(), font: "Synthetic.ttf".into(), backwards, upside_down, vertical, ..TextStyle::default() }, + flags, + generation, + )); + } + } + cases +} + +fn fixture(table: &str, body: &str) -> Drawing { + let bytes = format!("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\n{table}\n70\n1\n0\n{table}\n2\nFlags\n{body}0\nENDTAB\n0\nENDSEC\n0\nEOF\n"); + let drawing = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + assert!(drawing.read_notes.is_empty(), "{:?}", drawing.read_notes); + assert!(drawing.skipped_records.is_empty()); + drawing +} + +fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec { + let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect(); + assert_eq!(matching.len(), 1, "{version} binary={binary} {table}"); + matching.into_iter().next().unwrap().1 +} + +fn group(tags: &[Tag], expected: Tag, context: &str) { + assert_eq!(tags.iter().filter(|t| t.code == expected.code).collect::>(), [&expected], "{context}"); +} + +#[test] +fn reads_all_layer_flag_combinations() { + for (expected, flags) in layers() { + let aci = if expected.on { 3 } else { -3 }; + let plot = i64::from(expected.plot); + let drawing = fixture("LAYER", &format!("70\n{flags}\n62\n{aci}\n6\nContinuous\n290\n{plot}\n")); + assert_eq!(drawing.layer("Flags"), Some(&expected), "70={flags} 62={aci} 290={plot}"); + } +} + +#[test] +fn writes_all_layer_flag_combinations() { + for (layer, flags) in layers() { + let mut drawing = Drawing::new_metric(); + drawing.layers.push(layer.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "LAYER", version, binary); + let context = format!("{version} binary={binary} {layer:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(62, if layer.on { 3 } else { -3 }), &context); + if layer.plot { + // This writer omits the default true; the fixture above tests explicit true. + assert!(!tags.iter().any(|t| t.code == 290), "{context}"); + } else { + group(&tags, Tag::new(290, Value::Bool(false)), &context); + } + } + } + } +} + +#[test] +fn reads_all_native_text_style_flags() { + for (expected, flags, generation) in styles() { + let drawing = fixture("STYLE", &format!("70\n{flags}\n71\n{generation}\n40\n0\n41\n1\n50\n0\n3\nSynthetic.ttf\n4\n\n")); + assert_eq!(drawing.text_style("Flags"), Some(&expected), "70={flags} 71={generation}"); + } +} + +#[test] +fn writes_all_native_text_style_flags() { + for (style, flags, generation) in styles() { + let mut drawing = Drawing::new_metric(); + drawing.text_styles.push(style.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "STYLE", version, binary); + let context = format!("{version} binary={binary} {style:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(71, generation), &context); + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/handle_limits.rs b/crates/io/tests/it/roundtrip/handle_limits.rs new file mode 100644 index 000000000..3cff2b3e4 --- /dev/null +++ b/crates/io/tests/it/roundtrip/handle_limits.rs @@ -0,0 +1,40 @@ +//! Exhausting generated-handle space may be refused, but must not silently lose authored +//! entities. This is separate from the preservation matrix's large handles with free capacity. +//! Neither a particular allocator strategy nor a successful save is required at exhaustion. + +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases::properties, compare}; + +fn preserves_or_refuses(path: &str, binary: bool) { + let original = properties::exhausted_handles().drawing; + for version in Version::ALL { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("exhausted handles · {path} {version} binary={binary} pass={pass}"); + let Ok(bytes) = write_with(¤t, path, &options) else { + break; // An explicit save error is safe; a nominal success must preserve the data. + }; + let back = read(&bytes, path).unwrap_or_else(|e| panic!("{context}: successful save is unreadable: {e}")); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + compare::model(&original, &back, &context); + current = back; + } + } +} + +#[test] +#[ignore = "Confirmed DXF data loss: exhausted generated handles produce a successful save that reopens with no model entities"] +fn dxf_exhausted_handles_are_preserved_or_refused() { + for binary in [false, true] { + preserves_or_refuses("memory.dxf", binary); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn dwg_exhausted_handles_are_preserved_or_refused() { + preserves_or_refuses("memory.dwg", false); +} diff --git a/crates/io/tests/it/roundtrip/mod.rs b/crates/io/tests/it/roundtrip/mod.rs new file mode 100644 index 000000000..d38f84eb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/mod.rs @@ -0,0 +1,11 @@ +//! In-memory preservation tests grouped by file format. DXF and DWG use the same drawing cases +//! and strict assertions; each format owns its version domain and explicitly pending regressions. + +mod cases; +mod collections; +mod compare; +#[cfg(not(target_arch = "wasm32"))] +mod dwg; +mod dxf; +mod handle_limits; +mod unknown; diff --git a/crates/io/tests/it/roundtrip/unknown.rs b/crates/io/tests/it/roundtrip/unknown.rs new file mode 100644 index 000000000..5883a078c --- /dev/null +++ b/crates/io/tests/it/roundtrip/unknown.rs @@ -0,0 +1,106 @@ +//! Unknown-entity preservation is payload- and reference-aware: the entity's handle, Common +//! fields, model-space membership and every application payload tag must survive. Transport +//! owner handles may be reallocated, but the DXF owner must still resolve to model space. +//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it. + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown}; +use cadcraft_dxf::{records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn drawing() -> Drawing { + let mut d = super::cases::drawing(); + let handle = d.new_handle(); + let target = d.new_handle(); + let tags = vec![ + RawTag { code: 5, value: handle.hex() }, + // The store supplies the owner when saved, as it does for every modeled entity. + RawTag { code: 330, value: "0".into() }, + RawTag { code: 100, value: "AcDbEntity".into() }, + RawTag { code: 8, value: "0".into() }, + RawTag { code: 100, value: "SyntheticWidget".into() }, + RawTag { code: 1, value: "Original Ø payload".into() }, + RawTag { code: 90, value: "-7".into() }, + RawTag { code: 310, value: "00FF80".into() }, + RawTag { code: 10, value: "1.25".into() }, + RawTag { code: 20, value: "-2.5".into() }, + RawTag { code: 30, value: "3.75".into() }, + RawTag { code: 330, value: target.hex() }, + ]; + d.model.push(Entity::new(handle, EntityKind::Unknown(Unknown { dxf_type: "SYNTHETIC_WIDGET".into(), tags }))); + d.model.push(Entity::new(target, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + d +} + +fn payload(tags: &[RawTag]) -> &[RawTag] { + let start = tags.iter().position(|t| t.code == 100 && t.value == "SyntheticWidget").expect("application subclass must survive"); + &tags[start..] +} + +fn preserved(original: &Drawing, back: &Drawing, context: &str) { + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + assert_eq!(back.model.len(), original.model.len(), "{context}: entity count"); + for (a, b) in original.model.iter().zip(back.model.iter()) { + assert_eq!(b.handle, a.handle, "{context}: handle"); + assert_eq!(b.common, a.common, "{context}: Common"); + match (&a.kind, &b.kind) { + (EntityKind::Unknown(a), EntityKind::Unknown(b)) => { + assert_eq!(b.dxf_type, a.dxf_type, "{context}: application entity type"); + assert_eq!(payload(&b.tags), payload(&a.tags), "{context}: full payload including handle reference"); + } + _ => assert_eq!(b.kind, a.kind, "{context}: geometry"), + } + } +} + +fn dxf_owner(bytes: &[u8]) { + let tags = cadcraft_dxf::parse(bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let (_, model) = tables + .iter() + .find(|(kind, tags)| kind == "BLOCK_RECORD" && tags.iter().any(|t| t.code == 2 && t.str().eq_ignore_ascii_case("*Model_Space"))) + .unwrap(); + let model_handle = model.iter().find(|t| t.code == 5).unwrap().str(); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + let (_, widget) = entities.iter().find(|(kind, _)| kind == "SYNTHETIC_WIDGET").unwrap(); + assert_eq!(widget.iter().find(|t| t.code == 330).unwrap().str(), model_handle, "unknown entity belongs to the model-space block"); +} + +pub(super) fn check(path: &str, version: Version, binary: bool) { + let original = drawing(); + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("unknown entity {path} {version} binary={binary} pass={pass}"); + let bytes = match write_with(¤t, path, &SaveOptions { version: Some(version), binary }) { + Ok(bytes) => bytes, + Err(_) if path.ends_with(".dwg") && pass == 1 => return, + Err(error) => panic!("{context}: {error}"), + }; + if path.ends_with(".dxf") { + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + dxf_owner(&bytes); + } else { + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + } + let back = read(&bytes, path).unwrap(); + preserved(&original, &back, &context); + preserved(¤t, &back, &context); + current = back; + } +} + +#[test] +fn unknown_payload_assertion_rejects_lost_references() { + let original = drawing(); + preserved(&original, &original, "unchanged unknown entity"); + let mut changed = original.clone(); + let handle = changed.model.iter().next().unwrap().handle; + changed.model.modify(handle, |e| { + let EntityKind::Unknown(u) = &mut e.kind else { panic!("expected unknown entity") }; + u.tags.pop(); + }); + assert_eq!(original.model.iter().next().unwrap().common, Common::default()); + assert!(std::panic::catch_unwind(|| preserved(&original, &changed, "missing application reference")).is_err()); +}