collective

Browse / github.com/storytold/cadcraft / patch

fix(io): preserve implicit TEXT anchors in DWG output

Decide DWG export semantics for justified TEXT without a second alignment point ## Scope Contract decision / investigation, split from #2238 (B1); regression tests are in #2237. This issue tracks **one finding**, including any shared DXF/DWG manifestations. Decision/investigation, not a claim that None is directly representable in native DWG. Separate from DWG TEXT's established single-elevation limit and #2229's independent font-placement report. ## Observed result `None` becomes `Some(0,0,4.125)` for the Left/Bottom witness. Determine how an internal text with no second point should be exported without moving it, and distinguish this from the established single-elev

Status
open
Score
0.00
Adopters
0 other operators
Author
jonascloud (imported from pull request #2306, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+554 −19 in 7 files
Review
pending
Shared
2026-10-28
Id
8203aa2f94db3cc3d967620f

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/tests/text_implicit_alignment.rs crates/io/src/dxf_write.rs crates/io/src/dxf_write/common.rs crates/io/src/dxf_write/sections.rs crates/io/src/lib.rs crates/io/tests/it/main.rs crates/io/tests/it/text_implicit_alignment.rs

diff --git a/crates/engine/tests/text_implicit_alignment.rs b/crates/engine/tests/text_implicit_alignment.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/text_implicit_alignment.rs@@ -0,0 +1,128 @@+//! Making an implicit TEXT anchor explicit in DWG must preserve grip editing, property edits,+//! and undo/redo. These exercise the engine API and interactive prompt path, not OS mouse input.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{Common, Drawing, EntityKind, HAlign, Space, Text, VAlign};+use cadcraft_engine::geom::{Vec2, Vec3};+use cadcraft_engine::grips::GripMode;+use cadcraft_io::{SaveOptions, Version};+use serde_json::json;++fn reopened(d: &Drawing, version: Version) -> Drawing {+    let bytes = cadcraft_io::write_with(d, "text.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();+    cadcraft_io::read(&bytes, "text.dwg").unwrap()+}++fn geometry(d: &Drawing) -> Vec<Vec2> {+    let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default());+    assert!(!list.verts.is_empty());+    list.verts+}++fn same_geometry(a: &Drawing, b: &Drawing) {+    let a = geometry(a);+    let b = geometry(b);+    assert_eq!(a.len(), b.len());+    assert!(a.iter().zip(b).all(|(a, b)| a.dist(b) < 1e-10));+}++#[test]+fn rejected_dwg_save_keeps_existing_file_and_unsaved_drawing() {+    use cadcraft_engine::cmd::file::{IoHooks, set_io};+    use std::io::Write;++    set_io(IoHooks {+        read: |bytes, name| cadcraft_io::read(bytes, name).map_err(|e| e.to_string()),+        write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),+        plot: None,+    });+    let mut s = Session::new();+    let saved = s.state().unwrap().saved.clone();+    let previous_bytes = cadcraft_io::write(s.doc().unwrap(), "previous.dwg").unwrap();+    let path = std::env::temp_dir().join(format!("cadcraft-text-rejected-save-{}.dwg", std::process::id()));+    let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();+    file.write_all(&previous_bytes).unwrap();+    drop(file);+    s.execute("text", &json!({"at": [12, 7], "text": "AB", "height": 3})).unwrap();+    let handle = s.doc().unwrap().model.iter().next().unwrap().handle;+    s.doc_mut()+        .unwrap()+        .modify_entity(handle, |e| {+            let EntityKind::Text(t) = &mut e.kind else { panic!("TEXT") };+            t.halign = HAlign::Fit;+            t.align_pt = None;+        })+        .unwrap();+    let before = s.doc().unwrap().clone();+    let result = s.execute("saveas", &json!({"path": path.to_str().unwrap()}));+    let actual_bytes = std::fs::read(&path).unwrap();+    std::fs::remove_file(&path).unwrap();+    let error = result.unwrap_err();+    assert!(error.to_string().contains("second alignment point"), "{error}");+    assert_eq!(actual_bytes, previous_bytes);+    assert_eq!(s.doc().unwrap().model, before.model);+    assert!(s.state().unwrap().path.is_none());+    assert_eq!(s.state().unwrap().saved.model, saved.model);+}++#[test]+fn reopened_implicit_text_keeps_grips_property_edits_and_undo() {+    for (h, v) in+        [(HAlign::Left, VAlign::Bottom), (HAlign::Center, VAlign::Baseline), (HAlign::Right, VAlign::Top), (HAlign::Middle, VAlign::Baseline)]+    {+        let mut source = Drawing::new_metric();+        let handle = source+            .add(+                &Space::Model,+                Common::default(),+                EntityKind::Text(Text {+                    insert: Vec3::new(12.0, 7.0, 5.0),+                    align_pt: None,+                    height: 3.0,+                    value: "AB".into(),+                    rotation: 0.7,+                    width_factor: 1.25,+                    oblique: -0.2,+                    style: "Standard".into(),+                    halign: h,+                    valign: v,+                }),+            )+            .unwrap();+        for version in Version::ALL {+            for interactive in [false, true] {+                let mut edited = Vec::new();+                for d in [source.clone(), reopened(&source, version)] {+                    let before = d.clone();+                    let mut s = Session::new();+                    *s.doc_mut().unwrap() = d;+                    assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(12.0, 7.0)]);+                    if interactive {+                        s.set_selection(vec![handle]);+                        s.start_grip_edit(&[(handle, 0)]).unwrap();+                        s.cmdline("16,5").unwrap();+                        assert!(s.running.is_none(), "{}", s.prompt_text());+                    } else {+                        s.grip_edit(handle, 0, Vec2::new(16.0, 5.0), GripMode::Stretch).unwrap();+                    }+                    assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(16.0, 5.0)]);+                    let after = s.doc().unwrap().clone();+                    assert!(geometry(&before).iter().zip(geometry(&after)).all(|(a, b)| (*a + Vec2::new(4.0, -2.0)).dist(b) < 1e-10));+                    s.execute("undo", &json!({})).unwrap();+                    assert_eq!(s.doc().unwrap().model, before.model);+                    s.execute("redo", &json!({})).unwrap();+                    assert_eq!(s.doc().unwrap().model, after.model);+                    s.execute("properties.set", &json!({"handles": [handle.hex()], "text": "AB AB AB", "height": 4.5})).unwrap();+                    assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(16.0, 5.0)]);+                    let EntityKind::Text(t) = &s.doc().unwrap().entity(handle).unwrap().kind else { panic!("TEXT") };+                    assert_eq!(t.value, "AB AB AB");+                    assert_eq!(t.height, 4.5);+                    let back = reopened(s.doc().unwrap(), version);+                    same_geometry(s.doc().unwrap(), &back);+                    edited.push(back);+                }+                same_geometry(&edited[0], &edited[1]);+            }+        }+    }+}diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs--- a/crates/io/src/dxf_write.rs+++ b/crates/io/src/dxf_write.rs@@ -31,7 +31,10 @@ use ctx::Ctx; use entity::entity; use sections::tags_for; -struct W {+struct W<'a> {+    // DXF tag emission is infallible. The DWG bridge checks this result before encoding;+    // recording the first error here validates only records that are actually emitted.+    dwg_text: Option<&'a mut crate::Result<()>>,     t: Vec<Tag>,     next: u64,     /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).@@ -43,7 +46,7 @@ struct W {     reactors: HashMap<Handle, Vec<String>>, } -impl W {+impl W<'_> {     fn s(&mut self, c: i32, v: impl Into<String>) {         self.t.push(Tag::s(c, v));     }@@ -107,15 +110,17 @@ pub fn write(d: &Drawing) -> String {  /// The drawing's DXF tags, declaring `version` in `$ACADVER`. pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {-    tags_for(d, version, version)+    tags_for(d, version, version, None) }  /// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG /// version's (MULTILEADER from 2007).-pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {-    let mut tags = tags_for(d, Version::R2000, dwg);+pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> crate::Result<String> {+    let mut text_result = Ok(());+    let mut tags = tags_for(d, Version::R2000, dwg, Some(&mut text_result));+    text_result?;     bridge_markers(&mut tags);-    cadcraft_dxf::write_ascii(&tags)+    Ok(cadcraft_dxf::write_ascii(&tags)) }  /// Subclass markers the DWG bridge knows by another spelling (it matches them exactly):@@ -136,6 +141,7 @@ fn bridge_markers(tags: &mut [Tag]) { /// out. AutoLISP's `entget` is built on it. pub fn entity_groups(d: &Drawing, e: &Entity) -> (String, Vec<Tag>) {     let mut w = W {+        dwg_text: None,         t: Vec::new(),         next: d.handseed.max(0x100).saturating_add(0x1000),         pstyles: HashMap::new(),diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs--- a/crates/io/src/dxf_write/common.rs+++ b/crates/io/src/dxf_write/common.rs@@ -13,7 +13,7 @@ pub(super) fn annotative_xdata(w: &mut W) {  /// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension. pub(super) fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {-    let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };+    let mut ov = W { dwg_text: None, t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };     let mut sah = false;     for (k, v) in &dm.overrides {         let Some(field) = DimStyle::field_name(k) else { continue };@@ -187,7 +187,7 @@ fn attrib_flags(a: &Attrib) -> i64 { /// The insertion point (group 10) to write for `t`. A justified text is placed by its alignment /// point (group 11), and its insertion point is the start of its baseline, which AutoCAD reads /// from DWG as is (it recomputes it only for DXF): writing the alignment point there drew centred-/// and right-aligned text from the wrong end in AutoCAD (#1050).+/// and right-aligned text from the wrong end in AutoCAD. /// /// The stored point is kept when it is close to where CADCraft's font puts the baseline start: /// then it is AutoCAD's own (exact for its fonts). It is replaced when it is clearly stale: the@@ -197,9 +197,9 @@ fn attrib_flags(a: &Attrib) -> i64 { /// across it within a fifth of its height: Left Middle and Left Bottom text made here, whose /// alignment point is only half or a third of a height from the baseline start, must not pass /// for AutoCAD's.-fn text_insert(d: &Drawing, t: &Text) -> Vec3 {-    let computed = computed_text_insert(d, t);-    if t.align_pt.is_some_and(|a| a.xy() == t.insert.xy()) {+fn text_insert(d: &Drawing, t: &Text, align_pt: Option<Vec3>) -> Vec3 {+    let computed = computed_text_insert(d, t, align_pt);+    if align_pt.is_some_and(|a| a.xy() == t.insert.xy()) {         return computed;     }     let h = t.height.abs();@@ -207,7 +207,7 @@ fn text_insert(d: &Drawing, t: &Text) -> Vec3 {     let close = if t.value.contains('\n') {         // A multiline attribute's lines are placed by its embedded MTEXT: a one-line baseline         // start says little about them, so only the distance counts.-        let width = t.align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy()));+        let width = align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy()));         off.len() <= 0.1 * width + 0.5 * h     } else {         let width = cadcraft_fonts::line_width(&t.value, h, t.width_factor.abs());@@ -216,9 +216,9 @@ fn text_insert(d: &Drawing, t: &Text) -> Vec3 {     if close { t.insert } else { computed } } -fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 {+fn computed_text_insert(d: &Drawing, t: &Text, align_pt: Option<Vec3>) -> Vec3 {     let justified = !matches!((t.halign, t.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _));-    let Some(a) = t.align_pt.filter(|_| justified) else { return t.insert };+    let Some(a) = align_pt.filter(|_| justified) else { return t.insert };     let ts = d.text_style(&t.style).cloned().unwrap_or_default();     let (h, v) = cadcraft_render::text_align(t.halign, t.valign);     let p = cadcraft_fonts::TextParams {@@ -237,8 +237,37 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 {     if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } } +/// Resolve only an emitted record's DWG anchor; do not mutate the drawing or DXF's optional+/// point. Aligned/Fit need an authored endpoint: inventing one changes subsequent value edits.+fn dwg_alignment_point(t: &Text) -> crate::Result<Option<Vec3>> {+    if t.align_pt.is_some() || (t.halign == HAlign::Left && t.valign == VAlign::Baseline) {+        return Ok(t.align_pt);+    }+    if matches!(t.halign, HAlign::Aligned | HAlign::Fit) {+        return Err(crate::IoError::Format(format!("DWG export: {:?} text requires a second alignment point", t.halign)));+    }+    if !t.insert.x.is_finite() || !t.insert.y.is_finite() || !t.insert.z.is_finite() {+        return Err(crate::IoError::Format("DWG export: implicit text alignment point must be finite".into()));+    }+    Ok(Some(t.insert))+}+ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) {-    w.p(10, text_insert(d, t));+    let align_pt = if let Some(result) = w.dwg_text.as_deref_mut() {+        if result.is_err() {+            return;+        }+        match dwg_alignment_point(t) {+            Ok(point) => point,+            Err(error) => {+                *result = Err(error);+                return;+            }+        }+    } else {+        t.align_pt+    };+    w.p(10, text_insert(d, t, align_pt));     w.f(40, t.height);     w.s(1, &t.value);     if t.rotation != 0.0 {@@ -268,7 +297,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri     if h != 0 {         w.i(72, h);     }-    if let Some(a) = t.align_pt {+    if let Some(a) = align_pt {         w.p(11, a);     }     match attrib {diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs--- a/crates/io/src/dxf_write/sections.rs+++ b/crates/io/src/dxf_write/sections.rs@@ -10,12 +10,13 @@ use super::*;  /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the /// file ends up in: `version`, or the DWG version the bridge writes) has.-pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {+pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, dwg_text: Option<&mut crate::Result<()>>) -> Vec<Tag> {     // An in-place block edit in progress is not written: the drawing as before it.     let committed = d.committed();     let d: &Drawing = &committed;     // Structural handles start above every entity handle.     let mut w = W {+        dwg_text,         t: Vec::new(),         next: d.handseed.max(0x100).saturating_add(0x1000),         pstyles: HashMap::new(),@@ -487,7 +488,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Ta         w.i(70, 0);         let v = serde_json::to_value(s).unwrap_or_default();         let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default();-        let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };+        let mut newer = W { dwg_text: None, t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };         for (field, code, kind) in dxf_ext::DIM_CODES {             let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue };             // DIMPOST is written even when empty: a reader that defaults a missing one to "<>"diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs--- a/crates/io/src/lib.rs+++ b/crates/io/src/lib.rs@@ -99,7 +99,7 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>         // gradients, so a drawing from an R2000 DXF doesn't lose them by default.         "dwg" if !opts.binary => {             let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));-            cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)+            cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version)?.as_bytes(), version.code()).map_err(IoError::Format)         }         e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))),         _ => write_framed(d, name, None),diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs--- a/crates/io/tests/it/main.rs+++ b/crates/io/tests/it/main.rs@@ -62,6 +62,7 @@ mod svg_arc_budget; mod svg_tiny_extents; mod table_cellstyle_roundtrip; mod tables_objects_roundtrip;+mod text_implicit_alignment; mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip;diff --git a/crates/io/tests/it/text_implicit_alignment.rs b/crates/io/tests/it/text_implicit_alignment.rsnew file mode 100644--- /dev/null+++ b/crates/io/tests/it/text_implicit_alignment.rs@@ -0,0 +1,370 @@+//! DWG must preserve the placement and edits of TEXT whose ordinary alignment anchor is+//! implicit. Aligned/Fit without a second endpoint must fail explicitly, not gain a new+//! length constraint. DXF keeps its existing optional-point representation.++use cadcraft_doc::{Attrib, Block, Common, Drawing, Entity, EntityKind, HAlign, Insert, Space, Text, VAlign};+use cadcraft_geom::{Mat3, Vec2, Vec3};+use cadcraft_io::{SaveOptions, Version};+use std::sync::Arc;++fn text(h: HAlign, v: VAlign) -> Text {+    Text {+        insert: Vec3::new(12.0, 7.0, 5.0),+        align_pt: None,+        height: 3.0,+        value: "AB".into(),+        rotation: 0.7,+        width_factor: 1.25,+        oblique: -0.2,+        style: "Standard".into(),+        halign: h,+        valign: v,+    }+}++fn drawing(t: Text) -> Drawing {+    let mut d = Drawing::new_metric();+    d.add(&Space::Model, Common::default(), EntityKind::Text(t)).unwrap();+    d+}++fn first_text(d: &Drawing) -> &Text {+    let EntityKind::Text(t) = &d.model.iter().next().unwrap().kind else { panic!("TEXT") };+    t+}++fn reopen(d: &Drawing, name: &str, version: Version, binary: bool) -> Drawing {+    let bytes = cadcraft_io::write_with(d, name, &SaveOptions { version: Some(version), binary }).unwrap();+    let back = cadcraft_io::read(&bytes, name).unwrap();+    assert_eq!(cadcraft_io::save_version(&back), version);+    assert!(back.read_notes.is_empty(), "{:?}", back.read_notes);+    back+}++fn same_geometry(a: &Drawing, b: &Drawing) {+    let a = cadcraft_render::build(a, &Space::Model, &Default::default());+    let b = cadcraft_render::build(b, &Space::Model, &Default::default());+    assert!(!a.verts.is_empty());+    assert_eq!(a.prims.len(), b.prims.len());+    for (pa, pb) in a.prims.iter().zip(&b.prims) {+        assert_eq!(pa.kind, pb.kind);+        assert_eq!(a.points(pa).len(), b.points(pb).len());+        for (a, b) in a.points(pa).iter().zip(b.points(pb)) {+            // Wire angle conversions round slightly; the fixed fixture is only tens of units.+            assert!(a.dist(*b) < 1e-10, "vertex moved: {a:?} -> {b:?}");+        }+    }+}++fn edit(d: &mut Drawing) {+    let handle = d.model.iter().next().unwrap().handle;+    d.model.modify(handle, |e| {+        e.kind.transform(&Mat3::translate(Vec2::new(4.0, -2.0)));+        let EntityKind::Text(t) = &mut e.kind else { panic!("TEXT") };+        t.value = "AB AB AB".into();+    });+}++#[test]+fn dwg_keeps_implicit_anchors_geometry_and_value_edits() {+    for h in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Middle] {+        for v in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] {+            if h == HAlign::Middle && v != VAlign::Baseline {+                continue;+            }+            for flags in 0..4 {+                let mut source = drawing(text(h, v));+                source.text_styles[0].backwards = flags & 1 != 0;+                source.text_styles[0].upside_down = flags & 2 != 0;+                let mut edited_source = source.clone();+                edit(&mut edited_source);+                for version in Version::ALL {+                    let mut current = source.clone();+                    for _ in 0..2 {+                        current = reopen(&current, "text.dwg", version, false);+                        let back = first_text(&current);+                        assert_eq!((back.halign, back.valign), (h, v));+                        assert_eq!(back.justify_point(), first_text(&source).justify_point());+                        assert_eq!(back.align_pt, if h == HAlign::Left && v == VAlign::Baseline { None } else { Some(first_text(&source).insert) });+                        same_geometry(&source, &current);+                        let mut edited = current.clone();+                        edit(&mut edited);+                        same_geometry(&edited_source, &edited);+                        let edited_back = reopen(&edited, "edited.dwg", version, false);+                        assert_eq!(first_text(&edited_back).value, "AB AB AB");+                        assert_eq!(first_text(&edited_back).justify_point(), first_text(&edited_source).justify_point());+                        same_geometry(&edited_source, &edited_back);+                    }+                }+                assert_eq!(first_text(&source).align_pt, None, "saving does not mutate the drawing");+            }+        }+    }+}++#[test]+fn dwg_refuses_missing_two_point_alignment_but_keeps_explicit_endpoints() {+    for h in [HAlign::Aligned, HAlign::Fit] {+        let source = drawing(text(h, VAlign::Baseline));+        for version in Version::ALL {+            let Err(error) = cadcraft_io::write_with(&source, "text.dwg", &SaveOptions { version: Some(version), binary: false }) else {+                panic!("DWG must refuse {h:?} without its second endpoint");+            };+            assert!(error.to_string().contains("second alignment point"), "{error}");+            let mut explicit = text(h, VAlign::Baseline);+            explicit.align_pt = Some(Vec3::new(18.0, 7.0, 5.0));+            let explicit = drawing(explicit);+            let back = reopen(&explicit, "text.dwg", version, false);+            assert_eq!(first_text(&back).align_pt, first_text(&explicit).align_pt);+            same_geometry(&explicit, &back);+        }+    }+}++#[test]+fn dwg_keeps_authored_ordinary_alignment_points() {+    for (h, v) in+        [(HAlign::Left, VAlign::Bottom), (HAlign::Center, VAlign::Baseline), (HAlign::Right, VAlign::Top), (HAlign::Middle, VAlign::Baseline)]+    {+        let mut t = text(h, v);+        t.align_pt = Some(Vec3::new(20.0, 8.0, 5.0));+        let source = drawing(t);+        for version in Version::ALL {+            let back = reopen(&source, "explicit.dwg", version, false);+            assert_eq!(first_text(&back).align_pt, first_text(&source).align_pt);+            same_geometry(&source, &back);+        }+    }+}++#[test]+fn dxf_keeps_absent_alignment_points() {+    for h in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Middle, HAlign::Aligned, HAlign::Fit] {+        let source = drawing(text(h, if h == HAlign::Left { VAlign::Bottom } else { VAlign::Baseline }));+        for version in Version::ALL {+            for binary in [false, true] {+                let back = reopen(&source, "text.dxf", version, binary);+                let actual = first_text(&back);+                let mut expected = first_text(&source).clone();+                // Only degree/radian conversion roundoff is permitted, not point synthesis.+                assert!((actual.rotation - expected.rotation).abs() <= 8.0 * f64::EPSILON);+                assert!((actual.oblique - expected.oblique).abs() <= 8.0 * f64::EPSILON);+                expected.rotation = actual.rotation;+                expected.oblique = actual.oblique;+                assert_eq!(actual, &expected);+                same_geometry(&source, &back);+            }+        }+    }+}++#[test]+fn entity_inspection_keeps_the_authored_optional_point() {+    let source = drawing(text(HAlign::Right, VAlign::Top));+    let entity = source.model.iter().next().unwrap();+    let (kind, tags) = cadcraft_io::entity_groups(&source, entity);+    assert_eq!(kind, "TEXT");+    assert!(!tags.iter().any(|tag| matches!(tag.code, 11 | 21 | 31)));+    assert_eq!(tags.iter().find(|tag| tag.code == 10).unwrap().f64(), 12.0);+    assert_eq!(tags.iter().find(|tag| tag.code == 20).unwrap().f64(), 7.0);+    let back = reopen(&source, "text.dwg", Version::R2018, false);+    assert_eq!(first_text(&back).align_pt, Some(first_text(&source).insert));+    assert_eq!(cadcraft_io::entity_groups(&source, entity), (kind, tags));+}++fn attribute() -> Attrib {+    Attrib {+        tag: "ID".into(),+        // Nonbaseline ATTRIB reading currently drops vertical justification; isolate anchor export here.+        text: text(HAlign::Center, VAlign::Baseline),+        invisible: false,+        constant: false,+        prompt: String::new(),+        props: Default::default(),+        multiline: None,+        modes: Default::default(),+    }+}++fn texts(d: &Drawing) -> Vec<&Text> {+    d.model+        .iter()+        .chain(d.layouts.iter().flat_map(|l| l.entities.iter()))+        .chain(d.blocks.values().flat_map(|b| b.entities.iter()))+        .flat_map(|e| match &e.kind {+            EntityKind::Text(t) => vec![t],+            EntityKind::AttDef(a) => vec![&a.text],+            EntityKind::Insert(i) => i.attribs.iter().map(|a| &a.text).collect(),+            _ => Vec::new(),+        })+        .collect()+}++#[test]+fn invented_endpoint_changes_future_value_edits() {+    for h in [HAlign::Aligned, HAlign::Fit] {+        let mut t = text(h, VAlign::Baseline);+        let mut implicit = drawing(t.clone());+        let width = cadcraft_fonts::text_width(&cadcraft_fonts::TextFont::Stroke, &t.value, t.height, t.width_factor);+        t.align_pt = Some(t.insert + Vec3::new(width * t.rotation.cos(), width * t.rotation.sin(), 0.0));+        let mut invented = reopen(&drawing(t), "invented.dwg", Version::R2018, false);+        same_geometry(&implicit, &invented);+        edit(&mut implicit);+        edit(&mut invented);+        let a = cadcraft_render::build(&implicit, &Space::Model, &Default::default());+        let b = cadcraft_render::build(&invented, &Space::Model, &Default::default());+        assert_eq!(a.verts.len(), b.verts.len());+        let max_shift = a.verts.iter().zip(&b.verts).map(|(a, b)| a.dist(*b)).fold(0.0_f64, f64::max);+        assert!(max_shift > 1.0, "an invented endpoint silently introduces a length constraint");+    }+}++fn insert(block: &str) -> Insert {+    Insert {+        block: block.into(),+        insert: Vec3::ZERO,+        scale: Vec3::new(1.0, 1.0, 1.0),+        rotation: 0.0,+        attribs: vec![],+        cols: 1,+        rows: 1,+        col_spacing: 0.0,+        row_spacing: 0.0,+    }+}++#[test]+fn xref_contents_do_not_block_host_export() {+    let mut d = drawing(text(HAlign::Left, VAlign::Baseline));+    let mut external = Block::new("External");+    external.xref_path = Some("external.dxf".into());+    external.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Fit, VAlign::Baseline))));+    d.blocks.insert(external.name.clone(), Arc::new(external));+    let mut dependent = Block::new("External|Child");+    dependent.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Aligned, VAlign::Baseline))));+    d.blocks.insert(dependent.name.clone(), Arc::new(dependent));+    d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("External"))).unwrap();+    for version in Version::ALL {+        let back = reopen(&d, "host.dwg", version, false);+        assert!(back.block("External").unwrap().entities.is_empty());+        assert!(back.block("External|Child").is_none());+        // The same contents are rejected if they belong to a stored local block.+        let mut local = d.clone();+        Arc::make_mut(local.blocks.get_mut("External").unwrap()).xref_path = None;+        assert!(cadcraft_io::write_with(&local, "local.dwg", &SaveOptions { version: Some(version), binary: false }).is_err());+    }+    assert_eq!(d.block("External").unwrap().entities.len(), 1);+    assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}++#[test]+fn uncommitted_refedit_objects_do_not_block_export() {+    let mut d = Drawing::new_metric();+    let mut block = Block::new("Tag");+    let original = d.new_handle();+    block.entities.push(Entity::new(original, EntityKind::Text(text(HAlign::Left, VAlign::Bottom))));+    d.blocks.insert(block.name.clone(), Arc::new(block));+    let handle = d.add(&Space::Model, Common { visible: false, ..Default::default() }, EntityKind::Insert(insert("Tag"))).unwrap();+    let copy = d.add(&Space::Model, Common::default(), EntityKind::Text(text(HAlign::Fit, VAlign::Baseline))).unwrap();+    d.refedit = Some(cadcraft_doc::RefEdit {+        block: "Tag".into(),+        insert: handle,+        insert_was_visible: true,+        space: Space::Model,+        to_world: Mat3::IDENTITY,+        items: vec![cadcraft_doc::WorkItem { copy, source: Some(original) }],+        ghosts: vec![],+        released: vec![],+    });+    for version in Version::ALL {+        let back = reopen(&d, "during-edit.dwg", version, false);+        assert!(back.model.get(copy).is_none());+        assert!(back.model.get(handle).unwrap().common.visible);+        same_geometry(&d.committed(), &back);+        // Objects added from the host (source=None) survive discard and must be validated.+        let mut retained = d.clone();+        retained.refedit.as_mut().unwrap().items[0].source = None;+        assert!(cadcraft_io::write_with(&retained, "retained.dwg", &SaveOptions { version: Some(version), binary: false }).is_err());+    }+    assert!(d.refedit.is_some());+    assert!(d.model.get(copy).is_some());+    assert!(!d.model.get(handle).unwrap().common.visible);+    assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}++#[test]+fn emitted_text_records_reject_missing_endpoints_and_nonfinite_implicit_anchors() {+    let mut cases = vec![text(HAlign::Aligned, VAlign::Baseline), text(HAlign::Fit, VAlign::Baseline)];+    for coordinate in 0..3 {+        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {+            let mut t = text(HAlign::Right, VAlign::Top);+            match coordinate {+                0 => t.insert.x = value,+                1 => t.insert.y = value,+                _ => t.insert.z = value,+            }+            cases.push(t);+        }+    }+    for t in cases {+        for location in 0..5 {+            let mut d = Drawing::new_metric();+            let mut a = attribute();+            a.text = t.clone();+            match location {+                0 => {+                    d.add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap();+                }+                1 => {+                    d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Text(t.clone())).unwrap();+                }+                2 | 3 => {+                    let mut block = Block::new("Tag");+                    let kind = if location == 2 { EntityKind::Text(t.clone()) } else { EntityKind::AttDef(a) };+                    block.entities.push(Entity::new(d.new_handle(), kind));+                    d.blocks.insert(block.name.clone(), Arc::new(block));+                }+                _ => {+                    d.blocks.insert("Tag".into(), Arc::new(Block::new("Tag")));+                    let mut i = insert("Tag");+                    i.attribs.push(a);+                    d.add(&Space::Model, Common::default(), EntityKind::Insert(i)).unwrap();+                }+            }+            let before = d.clone();+            for version in Version::ALL {+                let Err(error) = cadcraft_io::write_with(&d, "invalid.dwg", &SaveOptions { version: Some(version), binary: false }) else {+                    panic!("location {location} must reject {t:?}");+                };+                let reason = if matches!(t.halign, HAlign::Aligned | HAlign::Fit) { "second alignment point" } else { "must be finite" };+                assert!(error.to_string().contains(reason), "{error}");+                assert!(d.model.same_as(&before.model), "failed export must not mutate the source");+                assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+            }+        }+    }+}++#[test]+fn dwg_resolves_text_records_in_blocks_layouts_and_attributes() {+    let mut d = drawing(text(HAlign::Left, VAlign::Bottom));+    d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Text(text(HAlign::Left, VAlign::Bottom))).unwrap();+    let mut block = Block::new("Anchors");+    block.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Left, VAlign::Bottom))));+    block.entities.push(Entity::new(d.new_handle(), EntityKind::AttDef(attribute())));+    let insert = insert("Anchors");+    // Stored records, not expanded references: even a cycle must terminate.+    block.entities.push(Entity::new(d.new_handle(), EntityKind::Insert(insert.clone())));+    d.blocks.insert(block.name.clone(), Arc::new(block));+    d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { attribs: vec![attribute()], ..insert })).unwrap();+    for version in Version::ALL {+        let back = reopen(&d, "locations.dwg", version, false);+        let records = texts(&back);+        assert_eq!(records.len(), 5);+        for t in records {+            assert_eq!(t.align_pt, Some(Vec3::new(12.0, 7.0, 5.0)));+            assert_eq!(t.justify_point(), Vec2::new(12.0, 7.0));+        }+    }+    assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}