Merge remote-tracking branch 'origin/main' into pr-2201

This commit is contained in:
Tanish Manku
2026-10-30 19:25:36 -07:00
40 changed files with 5249 additions and 72 deletions
+36
View File
@@ -69,6 +69,42 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
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<Decoder> {
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<u8>) -> bool {
let mut buf = [0; 4];
match d {
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
Decoder::Utf8 => return false,
Decoder::Encoding(e) => {
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
// A code page byte that is ASCII would read back as another character, and a NUL,
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
return false;
}
out.extend_from_slice(&bytes);
}
Decoder::Table(t) => {
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
out.push(0x80 | i);
}
}
true
}
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
match d {
+85 -21
View File
@@ -8,6 +8,7 @@
#![forbid(unsafe_code)]
use std::fmt::Write as _;
use std::io::Write as _;
mod version;
pub use version::Version;
@@ -421,17 +422,48 @@ 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, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
/// DXF that is valid UTF-8 as UTF-8.
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
return ascii(tags, Strings::Escaped(None));
}
bytes
}
/// How a writer stores string values.
#[derive(Clone, Copy)]
enum Strings {
/// As they are (R2007+).
Utf8,
/// ASCII as it is, other characters in this code page when it has them, otherwise as
/// `\U+XXXX` escapes (before R2007).
Escaped(Option<codepage::Decoder>),
}
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(tags.len() * 16);
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) => escape_unicode(s, utf8, &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
}
@@ -444,6 +476,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<u8> {
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<u8> {
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
}
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
out.extend_from_slice(BINARY_SENTINEL);
for t in tags {
@@ -456,9 +498,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
Value::Str(s) => s.clone(),
v => v.as_str(),
};
let mut text = String::with_capacity(s.len());
escape_unicode(&s, utf8, &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 => {
@@ -512,19 +554,27 @@ 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; with `utf8` they stay as they are.
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
/// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, 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. In every form a backslash
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
let chars: Vec<char> = 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 {
'\r' | '\n' => out.push(' '),
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c => {
match (c, strings) {
('\r' | '\n', _) => out.push(b' '),
('\\', _) if escape_at(i + 1) => {
out.extend_from_slice(b"\\U+005C");
}
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
(c, Strings::Escaped(_)) => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
let _ = write!(out, "\\U+{u:04X}");
@@ -534,6 +584,20 @@ fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
}
}
/// `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<u8>) -> 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 {
+116
View File
@@ -0,0 +1,116 @@
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
fn file(code_page: &str, text: &str) -> Vec<Tag> {
vec![
Tag::s(0, "SECTION"),
Tag::s(2, "HEADER"),
Tag::s(9, "$ACADVER"),
Tag::s(1, "AC1015"),
Tag::s(9, "$DWGCODEPAGE"),
Tag::s(3, code_page),
Tag::s(0, "ENDSEC"),
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "TEXT"),
Tag::s(1, text),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
]
}
fn contains(bytes: &[u8], part: &[u8]) -> bool {
bytes.windows(part.len()).any(|w| w == part)
}
#[test]
fn text_is_written_in_the_code_page() {
// (code page, text, the bytes written for it)
let cases: [(&str, &str, &[u8]); 6] = [
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
];
for (cp, text, raw) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
#[test]
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
let tags = file(cp, "Ação\r\nfim");
let bytes = write_ascii_in(&tags, cp);
assert!(bytes.is_ascii(), "{cp}");
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
}
}
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
#[test]
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
let tags = file("ANSI_1251", "СІ");
let ascii = write_ascii_in(&tags, "ANSI_1251");
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
let binary = write_binary_in(&tags, "ANSI_1251");
assert!(contains(&binary, b"\xD1\xB2"));
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
// Bytes that are not valid UTF-8 stay as they are.
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
}
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
/// would read as UTF-8.
#[test]
fn literal_escapes_survive_code_page_text() {
for (cp, text) in
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
{
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
#[test]
fn a_trail_byte_backslash_never_starts_an_escape() {
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
];
for (cp, text, written, never) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
+145
View File
@@ -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")]);
}
}
}
+119
View File
@@ -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<u8> {
(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::<i32>().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);
}
}
}
}
+148
View File
@@ -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(&current, version.utf8()) } else { write_ascii_as(&current, 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, ""),
],
);
}
+130 -30
View File
@@ -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<f64> {
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,92 @@ 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).
/// 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 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();
let font = TextFont::Outline(face);
let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom);
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, "{name}: {lo}..{hi}");
}
}
+10
View File
@@ -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<f64> {
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 {
+1
View File
@@ -33,6 +33,7 @@ pub(crate) use blocks::is_generated_arrow;
pub(crate) use blocks::table_block_entities;
use ctx::Ctx;
use entity::entity;
pub(crate) use header::code_page;
use sections::tags_for;
struct W {
+4 -4
View File
@@ -134,10 +134,10 @@ pub(super) fn header_vars(w: &mut W, d: &Drawing) {
dim_header_vars(w, d);
}
/// `$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`.
pub(super) 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() }
+7 -4
View File
@@ -92,10 +92,13 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
match Format::from_path(name) {
Some(Format::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
+49
View File
@@ -116,6 +116,55 @@ 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 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() {
+11 -5
View File
@@ -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}");
@@ -124,11 +126,15 @@ fn class_records_follow_the_version() {
}
}
/// 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
+3 -4
View File
@@ -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 == "Привет")));
}
+1
View File
@@ -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;
+308
View File
@@ -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<Case> {
vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())]
}
pub(super) fn geometry() -> Vec<Case> {
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<Case> {
["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into()
}
pub(super) fn layer_linetypes() -> Vec<Case> {
["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<Case> {
[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<Case> {
["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<Case> {
[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<Case> {
[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<Case> {
[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<Case> {
[false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into()
}
pub(super) fn thicknesses() -> Vec<Case> {
[-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<Case> {
["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<Case> {
vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })]
}
pub(super) fn extrusions() -> Vec<Case> {
[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<Case> {
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<Case> {
// 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<Case> {
[("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<Case> {
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<Case> {
[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<Case> {
[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<Case> {
[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<Case> {
[-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<Case> {
["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<Case> {
[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<Case> {
// 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<Case> {
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<Case> {
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, &params).to3(anchor.z);
}
cases.push(entity_case(&format!("text/alignment/{halign:?}/{valign:?}/point={align_pt:?}"), Common::default(), EntityKind::Text(t)));
}
}
}
cases
}
@@ -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<Case> {
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<Case> {
[(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<Case> {
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<Case> {
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<Case> {
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<Case> {
[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<Case> {
// 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<Case> {
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]
}
@@ -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<Case> {
[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<Case> {
[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<Case> {
[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<Case> {
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<Case> {
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<Case> {
[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()
}
@@ -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<Case> {
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<Case> {
[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<Case> {
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<Case> {
[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<Case> {
[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<Case> {
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<Case> {
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<Case> {
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<Case> {
(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<Case> {
[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<Case> {
["", " 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<Case> {
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<Case> {
[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<Case> {
[
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<Case> {
[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<Case> {
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<Case> {
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<Case> {
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<Case> {
[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()
}
@@ -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<Case> {
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<Case> {
(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<Case> {
[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<Case> {
["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<Case> {
[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<Case> {
(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<Case> {
// 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<Case> {
[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<Case> {
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<Case> {
(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<Case> {
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<Case> {
sparse_handles(&[0x1_0000, 0x1_0000_0000])
}
pub(crate) fn handles_near_integer_limits() -> Vec<Case> {
// 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<Case> {
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<Case> {
[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<Case> {
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<Case> {
(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<Case> {
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, &params).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<Case> {
[254, 255, 256, 1024]
.map(|length| {
entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() }))
})
.into()
}
+263
View File
@@ -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));
}
}
+374
View File
@@ -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<T: PartialEq + Debug>(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());
}
}
@@ -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:?}");
}
}
}
@@ -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<number> 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::<BTreeSet<_>>().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<Handle> {
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());
}
@@ -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);
}
}
@@ -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<Case>) {
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<Case>) {
// 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);
}
}
}
+75
View File
@@ -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<cases::Case>) {
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(&current, "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(&current, &back, &format!("{context} · resave stability"));
}
current = back;
}
}
@@ -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(&current, Version::R2018, binary, &context);
saved_text(&current, "first\nsecond", "first\\Psecond", &context);
same_geometry(&expected, &geometry(&current), &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(&current, Version::R2018, modern_binary, &context);
let indirect = reopen(&current, 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);
}
}
}
}
}
}
+197
View File
@@ -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<Tag>)> {
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(&sections.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::<Vec<_>>(), ["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::<Vec<_>>(), [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");
}
}
}
@@ -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(&sections.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::<Vec<_>>(), 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(&sections.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::<Vec<_>>(), [&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));
}
@@ -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<cases::Case>) {
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());
}
@@ -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<Case>) {
assert!(!cases.is_empty());
for case in cases {
super::check(&case.name, &case.drawing);
}
}
fn since_2007(cases: Vec<Case>) {
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);
}
@@ -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<Tag>,
}
fn fixtures() -> Vec<Fixture> {
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(&sections.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}");
}
}
}
}
}
+56
View File
@@ -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(&current, "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(&current, &back, &format!("{context} · resave stability"));
extra(&current, &back, &format!("{context} · resave stability"));
}
current = back;
}
}
}
@@ -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<Tag> {
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(&sections.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::<Vec<_>>(), [&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);
}
}
}
}
@@ -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(&current, 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);
}
+11
View File
@@ -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;
+106
View File
@@ -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(&sections.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(&sections.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(&current, 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(&current, &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());
}
+3 -2
View File
@@ -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 with that `$ACADVER`; UTF-8 text from 2007; CLASS instance counts (91) from 2004 only (#635; with them, AutoCAD LT 2026 stops responding on an R2000 file) |
| 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, 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
@@ -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); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement |
| 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); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement; 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 |
@@ -146,6 +146,7 @@ our output in AutoCAD black-box.
| 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) |
| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
| 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-20 | minor | DIMENSION: the `*D` block's MTEXT, and the MTEXT EXPLODE leaves, hold the diameter sign as `%%c` instead of the decoded U+2300, which no SHX font has, so other programs plotted diameter dimensions in an SHX style without the sign (#1645) |
| 2026-10-21 | minor | Proxy objects and entities keep their class through a save: group 91 renumbered to the written CLASSES (the class it names is kept), a proxy whose number names no class dropped (#1812) |
| 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) |
+2 -2
View File
@@ -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