Files
cadcraft/crates/io/tests/multiline_attributes.rs
3199de4951 Multiline attributes: read, draw and save their text (they were lost on save) (#854)
* Multiline attributes: read, draw and save their text

A multiline attribute (DXF attribute type 2, or 4 for a definition) has its
text as embedded MTEXT after its own groups (`101 Embedded Object`); its own
group 1 is empty. CADCraft read only the own groups, so the attribute drew
nothing, and a save wrote it back empty: its text was lost.

- `Attrib::multiline` keeps the multiline text; `text.value` holds its plain
  text, which ATTEDIT and the other value editors change; an edited value
  replaces the contents (`Attrib::multiline_text`).
- DXF read: ATTRIB and ATTDEF split at the marker; the MTEXT parsing is
  shared with MTEXT entities (`mtext_from`).
- Drawn as multiline text; ZOOM Extents counts it.
- DXF write (2007 and later; earlier versions get one line): attribute type,
  alignment point and the embedded MTEXT, only what differs from unrotated
  text, as AutoCAD writes it.
- INSERT and ATTSYNC give references the definition's multiline text placed
  with them; MOVE/ROTATE/SCALE, Properties rotation and scale, annotative
  scaling and FLATTEN carry it.
- Tests: synthetic multiline ATTDEF and ATTRIB (read, drawn, saved, edited,
  pre-2007 save, damaged embedded groups); engine INSERT, MOVE, ATTEDIT and
  ATTSYNC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Multiline attributes: read MTEXT direction from its own groups (#667, as #834 does)

#834 moves these lines' fix into the MTEXT reader; the same fix in the shared
mtext_from keeps the two compatible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 05:54:25 -04:00

338 lines
7.1 KiB
Rust

//! Multiline attributes (DXF attribute type 2, or 4 for a definition): their text is embedded
//! MTEXT after the attribute's own groups (`101 Embedded Object`). It is read, drawn, written
//! back and survives a save; edits to the value replace it.
//!
//! The fixture is synthetic, laid out as the DXF Reference describes these attributes: block TAG
//! with a multiline definition LABEL, and a reference whose LABEL attribute has its own text.
use cadcraft_doc::{Drawing, EntityKind};
use cadcraft_io::{SaveOptions, Version, read_dxf};
/// DXF text from lines of `code value` (the value may be empty).
fn dxf(src: &str) -> String {
let mut out = String::new();
for line in src.lines().map(str::trim).filter(|l| !l.is_empty()) {
let (code, value) = line.split_once(' ').unwrap_or((line, ""));
out.push_str(&format!("{code}\n{value}\n"));
}
out
}
const FIXTURE: &str = "
0 SECTION
2 HEADER
9 $ACADVER
1 AC1032
9 $HANDSEED
5 200
0 ENDSEC
0 SECTION
2 TABLES
0 TABLE
2 BLOCK_RECORD
5 1
330 0
100 AcDbSymbolTable
70 3
0 BLOCK_RECORD
5 1F
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 *Model_Space
340 0
0 BLOCK_RECORD
5 1E
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 *Paper_Space
340 0
0 BLOCK_RECORD
5 1D
330 1
100 AcDbSymbolTableRecord
100 AcDbBlockTableRecord
2 TAG
340 0
0 ENDTAB
0 ENDSEC
0 SECTION
2 BLOCKS
0 BLOCK
5 20
330 1F
100 AcDbEntity
8 0
100 AcDbBlockBegin
2 *Model_Space
70 0
10 0
20 0
30 0
3 *Model_Space
1
0 ENDBLK
5 21
330 1F
100 AcDbEntity
8 0
100 AcDbBlockEnd
0 BLOCK
5 24
330 1D
100 AcDbEntity
8 0
100 AcDbBlockBegin
2 TAG
70 2
10 0
20 0
30 0
3 TAG
1
0 LINE
5 30
330 1D
100 AcDbEntity
8 0
100 AcDbLine
10 0
20 0
30 0
11 20
21 0
31 0
0 ATTDEF
5 31
330 1D
100 AcDbEntity
8 0
100 AcDbText
10 2
20 2
30 0
40 2
1 SITE NAME
7 Standard
72 1
11 10
21 3
31 0
100 AcDbAttributeDefinition
280 0
3 Site
2 LABEL
70 0
73 0
74 1
280 1
71 4
72 0
11 10
21 3
31 0
101 Embedded Object
10 10
20 3
30 0
40 2
41 0
46 0
71 8
72 5
1 SITE\\PNAME
7 Standard
73 1
44 1
0 ENDBLK
5 25
330 1D
100 AcDbEntity
8 0
100 AcDbBlockEnd
0 ENDSEC
0 SECTION
2 ENTITIES
0 INSERT
5 40
330 1F
100 AcDbEntity
8 0
100 AcDbBlockReference
66 1
2 TAG
10 100
20 50
30 0
0 ATTRIB
5 41
330 40
100 AcDbEntity
8 0
100 AcDbText
10 102
20 52
30 0
40 2
1
7 Standard
72 1
11 110
21 53
31 0
100 AcDbAttribute
280 0
2 LABEL
70 0
74 1
280 1
71 2
72 1
11 110
21 53
31 0
101 Embedded Object
10 110
20 53
30 0
40 2
41 0
46 0
71 8
72 5
1 NORTH\\PWELL 7
7 Standard
73 1
44 1
0 SEQEND
5 42
330 40
100 AcDbEntity
8 0
0 ENDSEC
0 SECTION
2 OBJECTS
0 DICTIONARY
5 C
330 0
100 AcDbDictionary
281 1
0 ENDSEC
0 EOF
";
/// The drawing saved as the app saves it (`write`: its own version, AutoCAD 2018 here).
fn save(d: &Drawing) -> String {
String::from_utf8(cadcraft_io::write(d, "x.dxf").unwrap()).unwrap()
}
fn reference(d: &Drawing) -> &cadcraft_doc::Insert {
match d.model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i) } else { None }) {
Some(i) => i,
None => panic!("no reference"),
}
}
fn definition(d: &Drawing) -> &cadcraft_doc::Attrib {
match d.block("TAG").and_then(|b| b.entities.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a) } else { None })) {
Some(a) => a,
None => panic!("no definition"),
}
}
#[test]
fn multiline_attributes_are_read_with_their_text() {
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
let a = &reference(&d).attribs[0];
assert_eq!(a.tag, "LABEL");
assert_eq!(a.text.value, "NORTH\nWELL 7", "the value is the multiline text's plain text");
let m = a.multiline.as_ref().expect("multiline");
assert_eq!(m.mtext.contents, "NORTH\\PWELL 7");
assert_eq!((m.mtext.insert.x, m.mtext.insert.y, m.mtext.attach), (110.0, 53.0, 8));
// The attribute's own groups, not the embedded text's, give the single-line fields.
assert_eq!(a.text.insert.x, 102.0);
let ad = definition(&d);
assert_eq!((ad.tag.as_str(), ad.prompt.as_str()), ("LABEL", "Site"));
assert_eq!(ad.text.value, "SITE\nNAME");
assert_eq!(ad.multiline.as_ref().map(|m| m.mtext.contents.as_str()), Some("SITE\\PNAME"));
}
#[test]
fn a_multiline_attribute_is_drawn() {
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
let opts = cadcraft_render::Options::default();
let with = cadcraft_render::build(&d, &cadcraft_doc::Space::Model, &opts);
let mut bare = d.clone();
let first = bare.model.iter().next().map(|e| e.handle);
if let Some(e) = first {
bare.model.modify(e, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.attribs.clear();
}
});
}
let without = cadcraft_render::build(&bare, &cadcraft_doc::Space::Model, &opts);
assert!(
with.prims.len() > without.prims.len() + 4,
"the attribute's two lines of text are drawn ({} vs {})",
with.prims.len(),
without.prims.len()
);
// Its text sits where the embedded text is: around (110, 53), bottom-centre attached.
assert!(with.bounds.max.y > 53.5 && with.bounds.max.x > 105.0, "{:?}", with.bounds);
}
#[test]
fn multiline_attributes_survive_a_save() {
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
let out = save(&d);
assert_eq!(out.matches("Embedded Object").count(), 2, "the definition's and the attribute's");
let again = read_dxf(out.as_bytes()).unwrap();
assert_eq!(reference(&again).attribs[0], reference(&d).attribs[0]);
assert_eq!(definition(&again), definition(&d));
}
#[test]
fn an_edited_value_replaces_the_multiline_text() {
let mut d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
let h = d.model.iter().next().map(|e| e.handle).unwrap();
d.model.modify(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.attribs[0].text.value = "SOUTH {A}\nWELL 8".into();
}
});
let a = &reference(&d).attribs[0];
assert_eq!(a.multiline_text().map(|m| m.contents), Some("SOUTH \\{A\\}\\PWELL 8".to_string()));
let again = read_dxf(save(&d).as_bytes()).unwrap();
assert_eq!(reference(&again).attribs[0].text.value, "SOUTH {A}\nWELL 8");
}
#[test]
fn files_before_2007_get_single_line_attributes() {
let d = read_dxf(dxf(FIXTURE).as_bytes()).unwrap();
let out =
String::from_utf8(cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(Version::R2004), binary: false }).unwrap()).unwrap();
assert!(!out.contains("Embedded Object"));
let again = read_dxf(out.as_bytes()).unwrap();
let a = &reference(&again).attribs[0];
assert!(a.multiline.is_none());
// Single-line text can't hold a line break: the text stays, on one line.
assert_eq!(a.text.value, "NORTH WELL 7");
}
#[test]
fn damaged_embedded_text_does_not_panic() {
for (from, to) in [
("101 Embedded Object\n10 110", "101 Embedded Object"),
("71 8\n72 5\n1 NORTH", "71 99\n72 5\n1 NORTH"),
("40 2\n41 0\n46 0\n71 8", "40 nan\n41 -5\n46 x\n71 8"),
] {
let src = FIXTURE.replacen(from, to, 1);
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
let _ = cadcraft_render::build(&d, &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
let _ = read_dxf(save(&d).as_bytes()).unwrap();
}
}