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>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:54:25 -04:00
1 parent 48352f2baf
commit 3199de4951
20 files changed
+736 -72

No files matched your search

+5
View File
@@ -194,6 +194,11 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
a.text.insert = o + (a.text.insert - o) * f;
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
a.text.height *= f;
if let Some(ml) = &mut a.multiline {
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
ml.mtext.height *= f;
ml.mtext.width *= f;
}
}
}
_ => {}
+67 -1
View File
@@ -349,6 +349,68 @@ pub struct Attrib {
/// An attribute's own properties; each one left unset follows the attribute definition.
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
pub props: AttribProps,
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
/// multiline text. `text.value` holds the plain text, which value edits change.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiline: Option<MultilineAttrib>,
}
/// The multiline text of a multiline attribute.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultilineAttrib {
pub mtext: MText,
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
/// since, and its value replaces the contents.
pub read_as: String,
}
impl MultilineAttrib {
/// This text placed by `m` (a block reference's transform, for its attribute made from a
/// definition).
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
let mut k = EntityKind::MText(self.mtext.clone());
k.transform(m);
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
MultilineAttrib { mtext, read_as: self.read_as.clone() }
}
}
impl Attrib {
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
pub fn transform_multiline(&mut self, m: &Mat3) {
if let Some(ml) = &mut self.multiline {
*ml = ml.transformed(m);
}
}
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
/// replaced by the value when the value was edited since it was read.
pub fn multiline_text(&self) -> Option<MText> {
let m = self.multiline.as_ref()?;
let mut t = m.mtext.clone();
if self.text.value != m.read_as {
t.contents = mtext_literal(&self.text.value);
}
Some(t)
}
}
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
/// paragraph breaks.
pub fn mtext_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
'\n' => out.push_str("\\P"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
@@ -1165,7 +1227,10 @@ impl EntityKind {
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
}
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
EntityKind::AttDef(a) => {
transform_text(&mut a.text, m, s, rot, mirror);
a.transform_multiline(m);
}
EntityKind::MText(t) => {
t3(&mut t.insert);
t.height *= s;
@@ -1187,6 +1252,7 @@ impl EntityKind {
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
a.transform_multiline(m);
}
i.col_spacing *= s;
i.row_spacing *= s;
+13 -2
View File
@@ -132,7 +132,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
}
for a in &ins.attribs {
if !a.invisible {
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
b = b.union(&match a.multiline_text() {
Some(m) => mtext_box(&m),
None => text_box(&a.text, a.text.value.chars().count()),
});
}
}
b
@@ -314,7 +317,15 @@ mod tests {
#[test]
fn right_justified_attdef_is_left_of_alignment_point() {
let text = text(HAlign::Right, VAlign::Baseline);
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
let a = crate::Attrib {
tag: "HELLO".into(),
text,
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
let b = bounds(EntityKind::AttDef(a));
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
}
+2
View File
@@ -101,6 +101,7 @@ fn visible_attributes_of_a_missing_block_still_count() {
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
let ins = |block: &str, attribs: Vec<Attrib>| {
Entity::new(
@@ -437,6 +438,7 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
}),
);
let d = drawing_with_block(attdef);
+2
View File
@@ -204,6 +204,7 @@ pub(crate) fn insert_with(
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
});
}
}
@@ -276,6 +277,7 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
constant: false,
prompt: prompt.into(),
props: Default::default(),
multiline: None,
})
}
+9 -1
View File
@@ -97,7 +97,15 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
.map(|a| a.text.value.clone())
.unwrap_or_else(|| ad.text.value.clone());
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
Attrib {
tag: ad.tag.clone(),
text: t,
invisible: ad.invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
}
})
.collect()
}
+6
View File
@@ -1315,6 +1315,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
EntityKind::AttDef(a) => {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
EntityKind::MText(t) => z(&mut t.insert),
EntityKind::Insert(i) => {
@@ -1322,6 +1325,9 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
for a in &mut i.attribs {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
}
EntityKind::Dimension(d) => {
+7
View File
@@ -341,6 +341,12 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
q.y = r.y;
}
t.rotation = cadcraft_geom::norm_angle(t.rotation + turn);
if let Some(ml) = &mut a.multiline {
let r = ml.mtext.insert.xy().rotate_about(base, turn);
ml.mtext.insert.x = r.x;
ml.mtext.insert.y = r.y;
ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn);
}
}
}
if let Some(x) = num("scale").filter(|x| *x != 0.0) {
@@ -353,6 +359,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {
if let EntityKind::Text(t) = k {
a.text = t;
}
a.transform_multiline(&m);
}
}
i.scale = Vec3::new(x, x, x);
@@ -0,0 +1,93 @@
//! A block with a multiline attribute definition: its references get the multiline text placed
//! with them, it moves with them, and an attribute edit replaces it.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Attrib, Block, Common, Entity, EntityKind, Handle, MText, MultilineAttrib, Text};
use cadcraft_engine::geom::Vec3;
use serde_json::json;
/// A session whose block TAG has a multiline definition LABEL ("SITE" / "NAME") at (10, 3).
fn session() -> Session {
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
let text = Text {
insert: Vec3::new(2.0, 2.0, 0.0),
align_pt: None,
height: 2.0,
value: "SITE\nNAME".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: Default::default(),
valign: Default::default(),
};
let mtext = MText {
insert: Vec3::new(10.0, 3.0, 0.0),
height: 2.0,
width: 0.0,
attach: 8,
rotation: 0.0,
style: "Standard".into(),
contents: "SITE\\PNAME".into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let def = Attrib {
tag: "LABEL".into(),
text,
invisible: false,
constant: false,
prompt: "Site".into(),
props: Default::default(),
multiline: Some(MultilineAttrib { mtext, read_as: "SITE\nNAME".into() }),
};
let mut b = Block::new("TAG");
let h = d.new_handle();
b.entities.push(Entity { handle: h, common: Common::default(), kind: EntityKind::AttDef(def) });
d.blocks.insert("TAG".into(), Arc::new(b));
let mut s = Session::empty();
s.open_drawing(d, "tags", None);
s
}
fn label(s: &Session, h: Handle) -> Attrib {
match s.doc().unwrap().entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) => i.attribs[0].clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn references_get_the_multiline_text_and_carry_it() {
let mut s = session();
let r = s.execute("insert", &json!({ "name": "TAG", "at": [10, 5] })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
let a = label(&s, h);
assert_eq!(a.text.value, "SITE\nNAME");
let m = a.multiline_text().expect("multiline");
assert_eq!((m.insert.x, m.insert.y, m.contents.as_str()), (20.0, 8.0, "SITE\\PNAME"));
// MOVE carries it.
s.execute("move", &json!({ "handles": [h.hex()], "delta": [5, 0] })).unwrap();
let m = label(&s, h).multiline_text().unwrap();
assert_eq!((m.insert.x, m.insert.y), (25.0, 8.0));
// An edited value replaces the multiline text; ATTSYNC keeps the value and the text.
s.execute("attedit", &json!({ "handle": h.hex(), "values": { "label": "WELL 7" } })).unwrap();
assert_eq!(label(&s, h).multiline_text().map(|m| m.contents), Some("WELL 7".to_string()));
s.execute("attsync", &json!({ "name": "TAG" })).unwrap();
let a = label(&s, h);
assert_eq!(a.text.value, "WELL 7");
assert_eq!(a.multiline_text().map(|m| (m.contents, m.insert.x)), Some(("WELL 7".to_string(), 25.0)));
}
#[test]
fn a_value_given_on_insert_replaces_the_definition_text() {
let mut s = session();
let r = s.execute("insert", &json!({ "name": "TAG", "at": [0, 0], "attribs": { "LABEL": "NORTH" } })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert_eq!(label(&s, h).multiline_text().map(|m| m.contents), Some("NORTH".to_string()));
}
+9 -1
View File
@@ -437,7 +437,15 @@ fn attribs(d: &Drawing, ins: &Insert, values: &[(String, String)]) -> Vec<Attrib
k.transform(&m);
let EntityKind::Text(mut text) = k else { continue };
text.value = value.clone();
out.push(Attrib { tag: ad.tag.clone(), text, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() });
out.push(Attrib {
tag: ad.tag.clone(),
text,
invisible: ad.invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
});
}
out
}
+92 -52
View File
@@ -105,6 +105,69 @@ fn attrib_props(t: &T, insert_layer: &str) -> AttribProps {
}
}
/// An ATTRIB's or ATTDEF's groups split at its embedded multiline text (`101 Embedded Object`):
/// the attribute's own groups and, for a multiline attribute, the MTEXT groups after the marker.
fn split_embedded(tags: &[Tag]) -> (&[Tag], Option<&[Tag]>) {
match tags.iter().position(|t| t.code == 101) {
Some(i) => (tags.get(..i).unwrap_or(tags), tags.get(i + 1..)),
None => (tags, None),
}
}
/// A multiline attribute's text, from the MTEXT groups embedded in it.
fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option<MultilineAttrib> {
let mtext = mtext_from(embedded.filter(|e| !e.is_empty())?, c);
let read_as = cadcraft_fonts::plain_mtext(&mtext.contents);
Some(MultilineAttrib { mtext, read_as })
}
/// Multiline text from MTEXT groups (an MTEXT entity's, or a multiline attribute's embedded ones).
fn mtext_from(tags: &[Tag], c: &Common) -> MText {
let t = T(tags);
// Contents: code 3 chunks followed by code 1.
let mut contents = String::new();
for tg in tags.iter().filter(|x| x.code == 3) {
contents.push_str(&tg.str());
}
contents.push_str(&t.s(1).unwrap_or_default());
let contents = mtext_dxf::decode_contents(&contents);
let columns = mtext_dxf::read_columns(tags);
// The direction comes from the MTEXT's own groups only: in an R2018+ embedded object
// (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834).
let own = T(mtext_dxf::before_columns(tags));
let mut rotation = match (own.f(11), own.f(21)) {
(Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(),
_ => own.fd(50, 0.0).to_radians(),
};
let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8;
if c.extrusion.z < 0.0 {
// Insertion point and direction are WCS, but the line height runs along extrusion ×
// direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with
// the same footprint, the text turns round and left/right attachment swap.
rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI);
attach = match attach {
1 | 4 | 7 => attach + 2,
3 | 6 | 9 => attach - 2,
a => a,
};
}
MText {
insert: t.p(10),
height: t.fd(40, 0.2).max(1e-9),
width: match &columns {
Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w,
_ => t.fd(41, 0.0).max(0.0),
},
attach,
rotation,
style: t.s(7).unwrap_or_else(|| "Standard".into()),
contents,
line_spacing: t.fd(44, 1.0),
line_spacing_exact: t.i(73) == Some(2),
columns: columns.map(|(c, _)| c),
}
}
fn text_from(t: &T) -> Text {
let h = match t.i(72).unwrap_or(0) {
1 => HAlign::Center,
@@ -293,58 +356,26 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
"RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }),
"XLINE" => EntityKind::XLine(RayLine { base: t.p(10), dir: t.p(11) }),
"TEXT" => EntityKind::Text(text_from(&t)),
"MTEXT" => {
// Contents: code 3 chunks followed by code 1.
let mut contents = String::new();
for tg in tags.iter().filter(|x| x.code == 3) {
contents.push_str(&tg.str());
"MTEXT" => EntityKind::MText(mtext_from(tags, &c)),
"ATTDEF" => {
// A multiline definition has its text as embedded MTEXT after its own groups.
let (own, embedded) = split_embedded(tags);
let multiline = multiline_attrib(embedded, &c);
let own = T(own);
let mut text = text_from(&own);
if let Some(m) = &multiline {
text.value.clone_from(&m.read_as);
}
contents.push_str(&t.s(1).unwrap_or_default());
let contents = mtext_dxf::decode_contents(&contents);
let columns = mtext_dxf::read_columns(tags);
// The direction comes from the MTEXT's own groups only: in an R2018+ embedded object
// (group 101), 11 is the insertion point, not the X-axis direction (#667).
let own = T(mtext_dxf::before_columns(tags));
let mut rotation = match (own.f(11), own.f(21)) {
(Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(),
_ => own.fd(50, 0.0).to_radians(),
};
let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8;
if c.extrusion.z < 0.0 {
// Insertion point and direction are WCS, but the line height runs along extrusion ×
// direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with
// the same footprint, the text turns round and left/right attachment swap.
rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI);
attach = match attach {
1 | 4 | 7 => attach + 2,
3 | 6 | 9 => attach - 2,
a => a,
};
}
EntityKind::MText(MText {
insert: t.p(10),
height: t.fd(40, 0.2).max(1e-9),
width: match &columns {
Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w,
_ => t.fd(41, 0.0).max(0.0),
},
attach,
rotation,
style: t.s(7).unwrap_or_else(|| "Standard".into()),
contents,
line_spacing: t.fd(44, 1.0),
line_spacing_exact: t.i(73) == Some(2),
columns: columns.map(|(c, _)| c),
EntityKind::AttDef(Attrib {
tag: own.s(2).unwrap_or_default(),
text,
invisible: own.i(70).unwrap_or(0) & 1 != 0,
constant: own.i(70).unwrap_or(0) & 2 != 0,
prompt: own.s(3).unwrap_or_default(),
props: Default::default(),
multiline,
})
}
"ATTDEF" => EntityKind::AttDef(Attrib {
tag: t.s(2).unwrap_or_default(),
text: text_from(&t),
invisible: t.i(70).unwrap_or(0) & 1 != 0,
constant: t.i(70).unwrap_or(0) & 2 != 0,
prompt: t.s(3).unwrap_or_default(),
props: Default::default(),
}),
"INSERT" => {
// A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale,
// rotation and column spacing are in that OCS, where x runs the other way. In the WCS that
@@ -1135,13 +1166,22 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, ski
while let Some((k2, t2)) = recs.get(i) {
if k2 == "ATTRIB" {
let tt = T(t2);
// A multiline attribute has its text as embedded MTEXT after its own groups.
let (own, embedded) = split_embedded(t2);
let multiline = multiline_attrib(embedded, &common(&tt));
let own = T(own);
let mut text = text_from(&own);
if let Some(m) = &multiline {
text.value.clone_from(&m.read_as);
}
attribs.push(Attrib {
tag: tt.s(2).unwrap_or_default(),
text: text_from(&tt),
invisible: tt.i(70).unwrap_or(0) & 1 != 0,
tag: own.s(2).unwrap_or_default(),
text,
invisible: own.i(70).unwrap_or(0) & 1 != 0,
constant: false,
prompt: String::new(),
props: attrib_props(&tt, &ins_layer),
multiline,
});
i += 1;
} else {
+38
View File
@@ -418,6 +418,38 @@ fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, a: &Attrib) -> (Common,
(c, base)
}
/// A multiline attribute's groups after its own (DXF 2007 and later): attribute type 2 (ATTRIB)
/// or 4 (ATTDEF), its alignment point, then its text as embedded MTEXT (`101 Embedded Object`).
/// Only what differs from unrotated text goes in the embedded object, as AutoCAD writes it.
fn multiline_tags(w: &mut W, a: &Attrib, attdef: bool) {
let Some(m) = a.multiline_text() else { return };
w.i(71, if attdef { 4 } else { 2 });
w.i(72, if attdef { 0 } else { 1 });
w.p(11, a.text.align_pt.unwrap_or(a.text.insert));
w.s(101, "Embedded Object");
w.p(10, m.insert);
w.f(40, m.height);
w.f(41, m.width);
w.f(46, 0.0);
w.i(71, i64::from(m.attach));
w.i(72, 5);
let chars: Vec<char> = crate::dxf_read::mtext_dxf::encode_contents(&m.contents).chars().collect();
let chunks: Vec<String> = chars.chunks(250).map(|c| c.iter().collect()).collect();
let n = chunks.len();
for (i, c) in chunks.iter().enumerate() {
w.s(if i + 1 == n { 1 } else { 3 }, c.clone());
}
if n == 0 {
w.s(1, "");
}
w.s(7, &m.style);
if m.rotation != 0.0 {
w.p(11, Vec3::new(m.rotation.cos(), m.rotation.sin(), 0.0));
}
w.i(73, if m.line_spacing_exact { 2 } else { 1 });
w.f(44, m.line_spacing);
}
/// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant.
fn attrib_flags(a: &Attrib) -> i64 {
i64::from(a.invisible) | if a.constant { 2 } else { 0 }
@@ -672,6 +704,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.s(0, "ATTDEF");
common(w, e, owner, paper, "AcDbText");
text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), Some(&a.prompt));
if cx.target >= Version::R2007 {
multiline_tags(w, a, true);
}
}
EntityKind::MText(t) => {
w.s(0, "MTEXT");
@@ -733,6 +768,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
}
w.s(100, "AcDbText");
text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), None);
if cx.target >= Version::R2007 {
multiline_tags(w, a, false);
}
}
let sh = w.h();
w.s(0, "SEQEND");
+20 -6
View File
@@ -273,6 +273,7 @@ fn block_definition_properties_and_constant_attdefs_roundtrip() {
constant,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() {
b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a)));
@@ -1304,15 +1305,27 @@ fn intl_sample() -> Drawing {
}),
)
.unwrap();
let def =
Attrib {
tag: "图号".into(), text: intl_text("默认"), invisible: false, constant: false, prompt: String::new(), props: Default::default()
};
let def = Attrib {
tag: "图号".into(),
text: intl_text("默认"),
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
let mut b = Block::new(&block);
b.entities.push(Entity::new(Handle(0x50), EntityKind::AttDef(def)));
d.blocks.insert(block.clone(), std::sync::Arc::new(b));
let att =
Attrib { tag: "图号".into(), text: intl_text(INTL), invisible: false, constant: false, prompt: String::new(), props: Default::default() };
let att = Attrib {
tag: "图号".into(),
text: intl_text(INTL),
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
d.add(
&Space::Model,
on,
@@ -1571,6 +1584,7 @@ fn attdef_prompt_survives_dxf_roundtrip() {
constant: false,
prompt: prompt.into(),
props: Default::default(),
multiline: None,
})
};
d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap();
+1 -1
View File
@@ -24,7 +24,7 @@ fn text(x: f64, value: &str) -> Text {
}
fn attrib(tag: &str, x: f64, props: AttribProps) -> Attrib {
Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props }
Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props, multiline: None }
}
/// Block TAGS with definitions OWN (layer 0), DEFLAYER (layer TXT) and RED (layer 0, red),
+10 -1
View File
@@ -98,6 +98,7 @@ fn spline_leader_with_block_content_and_attributes() {
constant: false,
prompt: "Number".into(),
props: Default::default(),
multiline: None,
}),
));
d.blocks.insert(blk.name.clone(), std::sync::Arc::new(blk));
@@ -110,7 +111,15 @@ fn spline_leader_with_block_content_and_attributes() {
k.transform(&xf);
let EntityKind::Text(mut placed) = k else { unreachable!() };
placed.value = "42".into();
ins.attribs.push(Attrib { tag: "NUM".into(), text: placed, invisible: false, constant: false, prompt: String::new(), props: Default::default() });
ins.attribs.push(Attrib {
tag: "NUM".into(),
text: placed,
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
});
let m = MLeader {
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(4.0, 5.0, 0.0), Vec3::new(8.0, 4.0, 0.0)]],
landing: Vec3::new(10.0, 3.0, 0.0),
+337
View File
@@ -0,0 +1,337 @@
//! 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();
}
}
+9 -2
View File
@@ -1401,7 +1401,9 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
if attmode == 0 || (a.invisible && attmode != 2) {
continue;
}
let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value);
// A multiline attribute is drawn as its multiline text.
let multiline = a.multiline_text();
let sh = if multiline.is_none() { Some(place_text_entity(ctx.d, &a.text, &a.text.value).0) } else { None };
// Properties of the attribute's definition (as AutoCAD copies them to the attribute
// on insertion), except those set on the attribute itself: ByBlock = the reference's,
// layer 0 = the reference's layer.
@@ -1415,7 +1417,12 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
}
};
if shown {
b.shaped(ctx, ink, if b.opts.lineweights { lw } else { 0.0 }, &sh);
let lw = if b.opts.lineweights { lw } else { 0.0 };
match (&multiline, &sh) {
(Some(m), _) => mtext(b, ctx, m, ink, lw),
(None, Some(sh)) => b.shaped(ctx, ink, lw, sh),
(None, None) => {}
}
}
}
}
+10 -1
View File
@@ -113,7 +113,15 @@ fn text(x: f64, value: &str) -> Text {
}
fn attdef(handle: u64, tag: &str, x: f64, common: Common) -> Entity {
let a = Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props: Default::default() };
let a = Attrib {
tag: tag.into(),
text: text(x, tag),
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
};
Entity { common, ..Entity::new(Handle(handle), EntityKind::AttDef(a)) }
}
@@ -146,6 +154,7 @@ fn attributes_draw_with_their_own_properties() {
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
})
.to_vec();
let ins = Insert {
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dynamic block data read into a model) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and written) · **Target:** Autodesk AutoCAD 2027
Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
@@ -72,7 +72,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
|---|---|---|---|
| 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 | yes | ATTDEF prompt kept (#137) |
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) |
| 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 |
| LEADER | yes | yes | |
| MULTILEADER | yes (#459) | yes, native MULTILEADER with MLEADERSTYLE (#459) | several landings per multileader are folded into one; per-object overrides and annotative scale contexts not kept |
@@ -145,6 +145,7 @@ our output in AutoCAD black-box.
| 2026-10-12 | minor | Dynamic block references: visibility states can be changed and are saved as AutoCAD saves them |
| 2026-10-12 | minor | Dynamic block references: definition, values and custom properties read (`blocks.reference`, Properties ▸ Custom) |
| 2026-10-12 | minor | Dynamic block data row: parameters and actions read into a model (`blocks.dynamic`); kept by DXF and DWG save |
| 2026-10-12 | minor | Multiline attributes (embedded MTEXT) read, drawn and written back instead of losing their text |
| 2026-10-11 | minor | Rows refreshed for merged work: code pages and `\M+` (#505), MULTILEADER (#459), IMAGE, MLINE/HELIX/REGION/TOLERANCE, VPORT/VIEW/UCS tables, modelled and raw objects, foreign data (#528), DWT templates and user `.lin`/`.pat`/`.pgp` files (#552), PDF output (#567, #571), plotter devices (#564), data extraction (#433, #534); weighted percentages not re-measured |
| 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) |
| 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where CADCraft falls short of AutoCAD
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (F13: HATCHEDIT refuses a bad scale or angle before editing (#724); previously minor: SCRIPT command reading `.scr` files landed; E2 removed) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and saved: F17 narrowed) · **Target:** Autodesk AutoCAD 2027
Every known shortfall, one row each, ranked within each section by user impact. This is the work
list: agents pick from here (top of a section first), and remove or update a row in the same PR
@@ -40,7 +40,7 @@ do them first.
| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3). The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, and plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 |
| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 |
| F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset, lock-position or multiple-lines modes, so BATTMAN can't set them; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 |
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT has no Merged Tee and tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
@@ -152,6 +152,7 @@ Detail: [localization-parity.md](localization-parity.md).
| 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (deterministic xorshift generator, no new dependency; known answers at 1e6–1e7 coordinates): S6 removed |
| 2026-10-12 | trivial | F13: HATCHEDIT refuses a bad scale or angle before editing (#724) |
| 2026-10-12 | minor | F3: dynamic block references show their grips (distance arrows, position squares, visibility/lookup lists, per visibility state) and drag them (`blocks.reference.grips`, `blocks.reference.grip`) |
| 2026-10-12 | minor | Multiline attributes read, drawn and saved (they lost their text before): F17 narrowed to creating them |
| 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed |
| 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes |
| 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |