Two review findings on the multi-line-attribute paths of block_explode / block_set_attributes. COM: `_add_mtext_from_capture` wrote `InsertionPoint` before `AttachmentPoint`. ActiveX's `AttachmentPoint` keeps the text body where it is and relocates the anchor, so every non-TopLeft attribute was laid out TopLeft-at-anchor with only the label moved (live: a BottomLeft two-line note landed two lines low, BottomRight also a box width right). The anchor is now written last, unconditionally, after the attachment and the frame. Verified live on AutoCAD 2026: every alignment's bbox matches the control and the end-to-end burst MTEXT sits at the ATTRIB's TextAlignmentPoint. The fake MTEXT now relocates `InsertionPoint` on `AttachmentPoint`/`Normal` writes the way ActiveX does, both tests assert where the anchor ended up, and the alignment test uses BottomRight rather than the default TopLeft that could never show the displacement. ezdxf: `_write_attrib_value` relied on `set_mtext`, which mirrors only the first line into `dxf.text`; below R2018 ezdxf exports no embedded MTEXT, so the default R2010 document saved `NEW ONE` where the reader had promised `NEW ONE\PNEW TWO\PNEW THREE`. `dxf.text` now carries the whole value (AutoCAD's own `TextString` convention) after the MTEXT rebuild; the R2010 round-trip test uses a `\P` value, and an R2018 round trip pins both surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1289 lines
51 KiB
Python
1289 lines
51 KiB
Python
"""Track A hardening (v1.6 track E, wave 0): the block-tool defects the Track A
|
|
review found, fixed on both engines.
|
|
|
|
Headless tests run against a real ezdxf document; the live engine runs against
|
|
a fake ActiveX surface that records every call, so the tests assert the call
|
|
shape (what is dispatched, in which order, and — for a refusal — that nothing
|
|
is dispatched at all).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from backends.base import EntityInfo
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def _define_tagged_block(backend, name: str = "TB") -> None:
|
|
blk = backend._doc.blocks.new(name=name)
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
|
|
|
|
|
# ── fake ActiveX surface ──────────────────────────────────────────────────────
|
|
|
|
|
|
#: Members AutoCAD's ActiveX takes as a ``VT_ARRAY|VT_R8`` VARIANT. A plain
|
|
#: Python tuple marshals as ``VT_ARRAY|VT_VARIANT`` and is refused with
|
|
#: ``E_INVALIDARG`` (verified live on AutoCAD 2026: ``text.Normal = (0, 0, -1)``
|
|
#: raises, ``text.Normal = _apoint(0, 0, -1)`` succeeds); the fake refuses it
|
|
#: the same way so a test cannot pin the wrong call shape.
|
|
_POINT_MEMBERS = frozenset({"Normal", "InsertionPoint", "TextAlignmentPoint"})
|
|
|
|
|
|
class _FakeObject:
|
|
"""An ActiveX object with only the members it is given; property writes are recorded."""
|
|
|
|
def __init__(self, object_name: str, handle: str, **members):
|
|
object.__setattr__(self, "ObjectName", object_name)
|
|
object.__setattr__(self, "Handle", handle)
|
|
object.__setattr__(self, "writes", [])
|
|
object.__setattr__(self, "deleted", False)
|
|
for name, value in members.items():
|
|
object.__setattr__(self, name, value)
|
|
|
|
def __setattr__(self, name, value):
|
|
if name in _POINT_MEMBERS and not hasattr(value, "varianttype"):
|
|
raise RuntimeError(
|
|
f"E_INVALIDARG: {name} takes a VT_ARRAY|VT_R8 VARIANT, not {type(value).__name__}"
|
|
)
|
|
self.writes.append((name, value))
|
|
object.__setattr__(self, name, value)
|
|
|
|
def __getattr__(self, name):
|
|
raise AttributeError(name)
|
|
|
|
def Delete(self):
|
|
object.__setattr__(self, "deleted", True)
|
|
|
|
|
|
class _FakeMText(_FakeObject):
|
|
"""``AddMText``'s MTEXT. ActiveX ``AttachmentPoint`` (and ``Normal``) keep
|
|
the text *body* where it is and relocate ``InsertionPoint`` to the new
|
|
corner (verified live on AutoCAD 2026: attachment 7 on a two-line note
|
|
anchored at (10,10) moved the anchor to (10,-3.33)), so a writer that sets
|
|
the anchor first and the attachment second leaves the body TopLeft-at-
|
|
anchor. The fake displaces the anchor on those writes -- without recording
|
|
a write -- so a test asserts where the anchor *ended up*, not only what
|
|
was sent.
|
|
"""
|
|
|
|
def __setattr__(self, name, value):
|
|
super().__setattr__(name, value)
|
|
if name in ("AttachmentPoint", "Normal") and hasattr(self, "InsertionPoint"):
|
|
x, y, z = self.InsertionPoint.value
|
|
moved = types.SimpleNamespace(varianttype="VT_ARRAY|VT_R8", value=[x + 1.0, y - 2.0, z])
|
|
object.__setattr__(self, "InsertionPoint", moved)
|
|
|
|
|
|
class _FakeBlock:
|
|
def __init__(self, name: str, is_layout: bool = False, is_xref: bool = False):
|
|
self.Name = name
|
|
self.IsLayout = is_layout
|
|
self.IsXRef = is_xref
|
|
self.Count = 0
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
|
|
def __getattr__(self, attr):
|
|
if attr.startswith("Add"):
|
|
|
|
def _call(*args):
|
|
self.calls.append((attr, args))
|
|
return _FakeObject("AcDbEntity", f"M{len(self.calls)}")
|
|
|
|
return _call
|
|
raise AttributeError(attr)
|
|
|
|
|
|
class _FakeBlocks:
|
|
def __init__(self, *names: str):
|
|
self.blocks: dict[str, _FakeBlock] = {
|
|
"*Model_Space": _FakeBlock("*Model_Space", is_layout=True),
|
|
}
|
|
for name in names:
|
|
self.blocks[name] = _FakeBlock(name)
|
|
|
|
def Add(self, base_point, name):
|
|
self.blocks[name] = _FakeBlock(name)
|
|
self.blocks[name].base_point = tuple(base_point.value)
|
|
return self.blocks[name]
|
|
|
|
def Item(self, name):
|
|
if name not in self.blocks:
|
|
raise RuntimeError(f"no block {name}")
|
|
return self.blocks[name]
|
|
|
|
|
|
class _FakeLayers:
|
|
def __init__(self, *names: str):
|
|
self.names = {"0", *names}
|
|
self.added: list[str] = []
|
|
|
|
def Item(self, name):
|
|
if name not in self.names:
|
|
raise RuntimeError(f"no layer {name}")
|
|
return types.SimpleNamespace(Name=name)
|
|
|
|
def Add(self, name):
|
|
self.names.add(name)
|
|
self.added.append(name)
|
|
return types.SimpleNamespace(Name=name)
|
|
|
|
|
|
class _FakeSpace:
|
|
"""A layout block: records InsertBlock / AddText and hands back styled objects."""
|
|
|
|
def __init__(self, attributes: dict[str, str] | None = None, name: str = "*Model_Space"):
|
|
self.calls: list[tuple[str, tuple]] = []
|
|
self.attributes = attributes or {}
|
|
self.inserted: list[_FakeObject] = []
|
|
self.texts: list[_FakeObject] = []
|
|
self.Name = name
|
|
self.IsLayout = True
|
|
|
|
def InsertBlock(self, point, name, sx, sy, sz, rotation):
|
|
self.calls.append(("InsertBlock", (tuple(point.value), name, sx, sy, sz, rotation)))
|
|
attrs = tuple(
|
|
_FakeObject("AcDbAttribute", f"A{i}", TagString=tag, TextString=value)
|
|
for i, (tag, value) in enumerate(self.attributes.items())
|
|
)
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference", f"R{len(self.inserted)}", Name=name, GetAttributes=lambda: attrs
|
|
)
|
|
self.inserted.append(ref)
|
|
return ref
|
|
|
|
def AddText(self, text, point, height):
|
|
self.calls.append(("AddText", (text, tuple(point.value), height)))
|
|
obj = _FakeObject("AcDbText", f"T{len(self.calls)}")
|
|
self.texts.append(obj)
|
|
return obj
|
|
|
|
def AddMText(self, point, width, text):
|
|
self.calls.append(("AddMText", (tuple(point.value), width, text)))
|
|
obj = _FakeMText("AcDbMText", f"T{len(self.calls)}", InsertionPoint=point)
|
|
self.texts.append(obj)
|
|
return obj
|
|
|
|
|
|
@pytest.fixture
|
|
def com(monkeypatch):
|
|
"""A ComBackend over a recording fake: ``document`` and ``space`` are inspectable."""
|
|
from backends import com_backend as module
|
|
|
|
document = types.SimpleNamespace(
|
|
Blocks=_FakeBlocks("TB"), Layers=_FakeLayers(), objects={}, owners={}
|
|
)
|
|
document.HandleToObject = lambda handle: document.objects[handle]
|
|
# ``OwnerID`` -> block table record, the way ``ObjectIdToObject`` resolves it live.
|
|
document.ObjectIdToObject = lambda object_id: document.owners[object_id]
|
|
space = _FakeSpace()
|
|
document.owners[1] = space # the active layout; a sheet registers itself under 2
|
|
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
|
monkeypatch.setattr(module, "_msp", lambda: space)
|
|
monkeypatch.setattr(module, "_regen", lambda: None)
|
|
# The real converter reads a dozen ActiveX members (bounding box, block
|
|
# geometry, ...); the fixture only needs the handle and the name back.
|
|
monkeypatch.setattr(
|
|
module,
|
|
"_entity_info",
|
|
lambda ent: EntityInfo(
|
|
handle=ent.Handle,
|
|
type="INSERT",
|
|
layer="0",
|
|
color=256,
|
|
linetype="ByLayer",
|
|
visible=True,
|
|
properties={"block_name": ent.Name},
|
|
),
|
|
)
|
|
backend = module.ComBackend()
|
|
|
|
async def _run_inline(func, *args, **kwargs):
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(backend, "_run", _run_inline)
|
|
return backend, document, space
|
|
|
|
|
|
# ── Task 1: entity_create_block_ref refuses an undefined block ────────────────
|
|
|
|
|
|
async def test_block_ref_of_an_undefined_block_is_refused_before_writing(backend):
|
|
before = len(list(backend._msp()))
|
|
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
|
await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2)
|
|
assert len(list(backend._msp())) == before, "a refused insert must not write"
|
|
assert "NO_SUCH_BLOCK" not in backend._doc.blocks
|
|
|
|
|
|
async def test_block_ref_of_a_defined_block_still_inserts(backend):
|
|
_define_tagged_block(backend)
|
|
ref = await backend.entity_create_block_ref("TB", 5, 5, rotation=90)
|
|
info = await backend.entity_get(ref.handle)
|
|
assert info.type == "INSERT" and info.properties["block_name"] == "TB"
|
|
assert info.properties["rotation_deg"] == pytest.approx(90.0)
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["*Model_Space", "*Paper_Space"])
|
|
async def test_layout_blocks_are_refused_by_both_placing_tools(backend, name):
|
|
with pytest.raises(ValueError, match="layout block"):
|
|
await backend.entity_create_block_ref(name, 0, 0)
|
|
with pytest.raises(ValueError, match="layout block"):
|
|
await backend.block_insert(name, 0, 0)
|
|
assert backend._doc.audit().errors == [], "nothing was written, nothing to fix"
|
|
|
|
|
|
async def test_a_non_string_block_name_is_a_type_error_on_both_placing_tools(backend):
|
|
with pytest.raises(TypeError, match="non-empty string"):
|
|
await backend.entity_create_block_ref(None, 0, 0)
|
|
with pytest.raises(TypeError, match="non-empty string"):
|
|
await backend.block_insert("", 0, 0)
|
|
|
|
|
|
async def test_com_block_ref_probes_the_block_table_before_insert_block(com):
|
|
backend, document, space = com
|
|
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
|
await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2)
|
|
assert space.calls == [], "InsertBlock must not be dispatched for an unknown name"
|
|
with pytest.raises(ValueError, match="layout block"):
|
|
await backend.entity_create_block_ref("*Model_Space", 0, 0)
|
|
assert space.calls == []
|
|
|
|
ref = await backend.entity_create_block_ref("TB", 1, 2, 2.0, 2.0, 90.0)
|
|
assert ref.properties["block_name"] == "TB"
|
|
assert space.calls == [
|
|
("InsertBlock", ((1.0, 2.0, 0.0), "TB", 2.0, 2.0, 1.0, pytest.approx(1.5708, abs=1e-4)))
|
|
]
|
|
|
|
|
|
async def test_com_block_insert_shares_the_same_gate(com):
|
|
backend, document, space = com
|
|
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
|
await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"})
|
|
assert space.calls == []
|
|
|
|
|
|
# ── Task 2: block_explode keeps ATTRIB text ──────────────────────────────────
|
|
|
|
|
|
async def test_explode_emits_a_text_per_attrib_at_the_attribs_placement(backend):
|
|
from ezdxf.enums import TextEntityAlignment
|
|
|
|
blk = backend._doc.blocks.new(name="TB2")
|
|
blk.add_line((0, 0), (10, 0))
|
|
tag = blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
|
tag.set_placement((0, 3), align=TextEntityAlignment.CENTER)
|
|
hidden = blk.add_attdef("LINK", insert=(0, -3), text="L-1", dxfattribs={"height": 2.0})
|
|
hidden.is_invisible = True
|
|
ref = await backend.block_insert(
|
|
"TB2", 10, 10, scale_x=2.0, scale_y=2.0, rotation=30.0, attributes={"TAG": "P-101"}
|
|
)
|
|
raw_ref = backend._doc.entitydb[ref.handle]
|
|
expected = {a.dxf.tag: (tuple(a.dxf.insert)[:2], a.dxf.height) for a in raw_ref.attribs}
|
|
|
|
result = await backend.block_explode(ref.handle)
|
|
|
|
assert result["ok"] is True and result["exploded_handle"] == ref.handle
|
|
assert result["backend"] == "ezdxf"
|
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
|
|
assert len(result["attribute_texts"]) == 2
|
|
texts = {}
|
|
for handle in result["attribute_texts"]:
|
|
info = await backend.entity_get(handle)
|
|
assert info.type == "TEXT"
|
|
texts[info.properties["text"]] = info
|
|
tag_text = texts["P-101"]
|
|
assert tuple(tag_text.properties["insertion"]) == pytest.approx(expected["TAG"][0])
|
|
assert tag_text.properties["height"] == expected["TAG"][1] == 5.0
|
|
assert tag_text.properties["rotation"] == pytest.approx(30.0)
|
|
raw = backend._doc.entitydb[tag_text.handle]
|
|
assert raw.get_align_enum().name == "CENTER", "alignment survives the conversion"
|
|
assert raw.dxf.invisible == 0
|
|
assert backend._doc.entitydb[texts["L-1"].handle].dxf.invisible == 1
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
await backend.entity_get(ref.handle)
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_without_attribs_reports_an_empty_list(backend):
|
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
|
await backend.block_create_from_entities("PLAIN", [line.handle])
|
|
ref = await backend.block_insert("PLAIN", 0, 0)
|
|
result = await backend.block_explode(ref.handle)
|
|
assert result["attribute_texts"] == [] and len(result["inserted_handles"]) == 1
|
|
|
|
|
|
async def test_explode_refuses_a_non_insert_without_writing(backend):
|
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
|
before = len(list(backend._msp()))
|
|
with pytest.raises(RuntimeError, match="not a block reference"):
|
|
await backend.block_explode(line.handle)
|
|
assert len(list(backend._msp())) == before
|
|
|
|
|
|
async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_reference(com):
|
|
backend, document, space = com
|
|
attdef = _FakeObject("AcDbAttributeDefinition", "D1")
|
|
line = _FakeObject("AcDbLine", "L1")
|
|
visible = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="TAG",
|
|
TextString="P-101",
|
|
InsertionPoint=(7.0, 15.2, 0.0),
|
|
Height=5.0,
|
|
Rotation=0.5236,
|
|
Layer="PID-TAG",
|
|
Invisible=False,
|
|
)
|
|
hidden = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A2",
|
|
TagString="LINK",
|
|
TextString="L-1",
|
|
InsertionPoint=(13.0, 4.8, 0.0),
|
|
Height=4.0,
|
|
Rotation=0.5236,
|
|
Layer="0",
|
|
Invisible=True,
|
|
)
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"2F",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (visible, hidden),
|
|
Explode=lambda: (line, attdef),
|
|
)
|
|
document.objects["2F"] = ref
|
|
|
|
result = await backend.block_explode("2F")
|
|
|
|
assert result == {
|
|
"ok": True,
|
|
"exploded_handle": "2F",
|
|
"inserted_handles": ["L1"],
|
|
"attribute_texts": ["T1", "T2"],
|
|
"backend": "com",
|
|
}
|
|
assert attdef.deleted is True, "the value-less ATTDEF placeholder is removed"
|
|
assert line.deleted is False
|
|
assert ref.deleted is True, "ActiveX Explode leaves the reference; BURST removes it"
|
|
assert [c for c in space.calls] == [
|
|
("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)),
|
|
("AddText", ("L-1", (13.0, 4.8, 0.0), 4.0)),
|
|
]
|
|
|
|
|
|
async def test_com_explode_refuses_a_non_insert_before_any_call(com):
|
|
backend, document, space = com
|
|
document.objects["3A"] = _FakeObject("AcDbLine", "3A")
|
|
with pytest.raises(RuntimeError, match="not a block reference"):
|
|
await backend.block_explode("3A")
|
|
assert space.calls == []
|
|
|
|
|
|
async def test_com_explode_adds_the_text_to_the_owner_block_not_the_active_layout(com):
|
|
"""ActiveX ``Explode()`` places the members in the owner space; the tag
|
|
TEXT must land beside them, not in whatever tab is active."""
|
|
backend, document, space = com
|
|
sheet = _FakeSpace(name="Sheet")
|
|
document.owners[2] = sheet
|
|
title = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="TITLE",
|
|
TextString="GEAR-01",
|
|
InsertionPoint=(100.0, 50.0, 0.0),
|
|
Height=3.5,
|
|
Rotation=0.0,
|
|
Layer="TITLEBLOCK",
|
|
Invisible=False,
|
|
)
|
|
line = _FakeObject("AcDbLine", "L1")
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"4B",
|
|
Name="TB",
|
|
OwnerID=2,
|
|
GetAttributes=lambda: (title,),
|
|
Explode=lambda: (line,),
|
|
)
|
|
document.objects["4B"] = ref
|
|
|
|
result = await backend.block_explode("4B")
|
|
|
|
assert result["ok"] is True and result["inserted_handles"] == ["L1"]
|
|
assert sheet.calls == [("AddText", ("GEAR-01", (100.0, 50.0, 0.0), 3.5))]
|
|
assert space.calls == [], "nothing may be written into the active layout"
|
|
assert ref.deleted is True
|
|
|
|
|
|
async def test_com_explode_refuses_a_nested_reference_before_explode(com):
|
|
backend, document, space = com
|
|
document.owners[3] = _FakeBlock("OUTER") # a block definition, IsLayout False
|
|
dispatched = []
|
|
|
|
def _explode():
|
|
dispatched.append("Explode")
|
|
return ()
|
|
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"5C",
|
|
Name="TB",
|
|
OwnerID=3,
|
|
GetAttributes=lambda: (),
|
|
Explode=_explode,
|
|
)
|
|
document.objects["5C"] = ref
|
|
with pytest.raises(RuntimeError, match="nested inside block definition 'OUTER'"):
|
|
await backend.block_explode("5C")
|
|
assert dispatched == [] and space.calls == [] and ref.deleted is False
|
|
|
|
|
|
async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com):
|
|
"""The COM twin of the mirrored-reference test. ``AddText`` builds a +Z
|
|
TEXT; an ATTRIB reflected to ``Normal (0, 0, -1)`` has an OCS ``Rotation``
|
|
and mirrored glyphs, so the TEXT must take the frame *before* the angle
|
|
and have its WCS anchor re-asserted after the frame changes it. The
|
|
style members ezdxf carries (style, oblique, width, generation flags,
|
|
alignment + alignment point, colour) ride along the same way."""
|
|
backend, document, space = com
|
|
attrib = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="TAG",
|
|
TextString="P-101",
|
|
InsertionPoint=(-25.0, 13.0, 0.0),
|
|
Height=2.5,
|
|
Rotation=0.5236,
|
|
Layer="PID-TAG",
|
|
Invisible=False,
|
|
Normal=(0.0, 0.0, -1.0),
|
|
Thickness=0.7,
|
|
StyleName="ISO",
|
|
ObliqueAngle=0.2618,
|
|
ScaleFactor=0.8,
|
|
Backward=True,
|
|
UpsideDown=False,
|
|
Alignment=4,
|
|
TextAlignmentPoint=(-20.0, 13.0, 0.0),
|
|
Color=3,
|
|
)
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"6D",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (attrib,),
|
|
Explode=lambda: (),
|
|
)
|
|
document.objects["6D"] = ref
|
|
|
|
result = await backend.block_explode("6D")
|
|
|
|
assert result["ok"] is True and result["attribute_texts"] == ["T1"]
|
|
assert space.calls == [("AddText", ("P-101", (-25.0, 13.0, 0.0), 2.5))]
|
|
text = space.texts[0]
|
|
# Point writes go through ``_apoint`` (a VARIANT); compare their payload.
|
|
import pythoncom
|
|
|
|
for name, value in text.writes:
|
|
if name in _POINT_MEMBERS:
|
|
assert value.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8, (
|
|
f"{name} must be a double-array VARIANT, not a tuple (E_INVALIDARG live)"
|
|
)
|
|
writes = [
|
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
|
for name, value in text.writes
|
|
]
|
|
names = [name for name, _ in writes]
|
|
assert ("Normal", (0.0, 0.0, -1.0)) in writes, "the ATTRIB's frame is carried"
|
|
assert names.index("Normal") < names.index("Rotation"), (
|
|
"Rotation is the OCS angle: it only means the same thing inside the same frame"
|
|
)
|
|
assert names.index("Normal") < names.index("InsertionPoint"), (
|
|
"the WCS anchor is re-asserted after the frame change moves the OCS origin"
|
|
)
|
|
assert names.index("Alignment") < names.index("TextAlignmentPoint"), (
|
|
"ActiveX rejects an alignment point on a left-aligned text"
|
|
)
|
|
assert dict(writes) == {
|
|
"Normal": (0.0, 0.0, -1.0),
|
|
"Thickness": 0.7,
|
|
"StyleName": "ISO",
|
|
"ObliqueAngle": 0.2618,
|
|
"ScaleFactor": 0.8,
|
|
"Backward": True,
|
|
"UpsideDown": False,
|
|
"Rotation": 0.5236,
|
|
"InsertionPoint": (-25.0, 13.0, 0.0),
|
|
"Alignment": 4,
|
|
"TextAlignmentPoint": (-20.0, 13.0, 0.0),
|
|
"Layer": "PID-TAG",
|
|
"Color": 3,
|
|
}
|
|
|
|
|
|
async def test_com_explode_skips_frame_members_the_attrib_does_not_expose(com):
|
|
"""A bare ATTRIB (the shape the earlier fakes use) still explodes: an
|
|
absent optional member is skipped, never written as a guess, and a
|
|
left-aligned ATTRIB never gets a TextAlignmentPoint."""
|
|
backend, document, space = com
|
|
attrib = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="TAG",
|
|
TextString="P-101",
|
|
InsertionPoint=(7.0, 15.2, 0.0),
|
|
Height=5.0,
|
|
Rotation=0.0,
|
|
Layer="0",
|
|
Invisible=False,
|
|
Alignment=0,
|
|
TextAlignmentPoint=(99.0, 99.0, 0.0),
|
|
)
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"7E",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (attrib,),
|
|
Explode=lambda: (),
|
|
)
|
|
document.objects["7E"] = ref
|
|
|
|
result = await backend.block_explode("7E")
|
|
|
|
assert result["ok"] is True
|
|
names = [name for name, _ in space.texts[0].writes]
|
|
assert "Normal" not in names and "StyleName" not in names
|
|
assert "InsertionPoint" not in names, "a +Z text keeps the anchor AddText was given"
|
|
assert "TextAlignmentPoint" not in names, "left alignment has no alignment point"
|
|
assert names == ["Rotation", "Alignment", "Layer"]
|
|
|
|
|
|
async def test_com_explode_turns_a_constant_attdef_into_text_instead_of_deleting_it(com):
|
|
"""``GetAttributes()`` excludes constant attributes (``GetConstantAttributes()``
|
|
lists them) and ``Explode()`` returns each as an ``AcDbAttributeDefinition``
|
|
already at its WCS placement showing its value. Deleting every ATTDEF
|
|
destroyed that text with ``ok: True``; BURST converts it to TEXT."""
|
|
backend, document, space = com
|
|
tag = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="TAG",
|
|
TextString="P-101",
|
|
InsertionPoint=(7.0, 15.2, 0.0),
|
|
Height=5.0,
|
|
Rotation=0.0,
|
|
Layer="PID-TAG",
|
|
Invisible=False,
|
|
)
|
|
placeholder = _FakeObject("AcDbAttributeDefinition", "D1", TagString="TAG", Constant=False)
|
|
constant = _FakeObject(
|
|
"AcDbAttributeDefinition",
|
|
"D2",
|
|
TagString="CONST",
|
|
TextString="ACME",
|
|
Constant=True,
|
|
InsertionPoint=(25.0, 7.0, 0.0),
|
|
Height=2.0,
|
|
Rotation=0.5236,
|
|
Layer="TITLEBLOCK",
|
|
Invisible=False,
|
|
Alignment=4,
|
|
TextAlignmentPoint=(25.0, 7.0, 0.0),
|
|
)
|
|
line = _FakeObject("AcDbLine", "L1")
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"8F",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (tag,),
|
|
GetConstantAttributes=lambda: (constant,),
|
|
Explode=lambda: (line, placeholder, constant),
|
|
)
|
|
document.objects["8F"] = ref
|
|
|
|
result = await backend.block_explode("8F")
|
|
|
|
assert result["inserted_handles"] == ["L1"]
|
|
assert result["attribute_texts"] == ["T1", "T2"], "the constant value is a TEXT too"
|
|
assert placeholder.deleted is True and constant.deleted is True and ref.deleted is True
|
|
assert space.calls == [
|
|
("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)),
|
|
("AddText", ("ACME", (25.0, 7.0, 0.0), 2.0)),
|
|
]
|
|
writes = [
|
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
|
for name, value in space.texts[1].writes
|
|
]
|
|
assert writes == [
|
|
("Rotation", 0.5236),
|
|
("Alignment", 4),
|
|
("TextAlignmentPoint", (25.0, 7.0, 0.0)),
|
|
("Layer", "TITLEBLOCK"),
|
|
], "the constant ATTDEF's placement, angle and alignment ride onto its TEXT"
|
|
|
|
|
|
async def test_com_explode_falls_back_to_the_constant_tag_list_when_constant_is_absent(com):
|
|
"""An object that does not expose ``Constant`` is still recognised through
|
|
``GetConstantAttributes()``; a value-less placeholder is still just deleted."""
|
|
backend, document, space = com
|
|
constant = _FakeObject(
|
|
"AcDbAttributeDefinition",
|
|
"D2",
|
|
TagString="CONST",
|
|
TextString="ACME",
|
|
InsertionPoint=(1.0, 2.0, 0.0),
|
|
Height=2.0,
|
|
Rotation=0.0,
|
|
Layer="0",
|
|
)
|
|
placeholder = _FakeObject("AcDbAttributeDefinition", "D1", TagString="TAG")
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"9A",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (),
|
|
GetConstantAttributes=lambda: (constant,),
|
|
Explode=lambda: (placeholder, constant),
|
|
)
|
|
document.objects["9A"] = ref
|
|
|
|
result = await backend.block_explode("9A")
|
|
|
|
assert result["attribute_texts"] == ["T1"]
|
|
assert space.calls == [("AddText", ("ACME", (1.0, 2.0, 0.0), 2.0))]
|
|
assert placeholder.deleted is True and constant.deleted is True
|
|
|
|
|
|
def _define_block_with_constant_attdef(backend, name: str = "TBC") -> None:
|
|
from ezdxf.enums import TextEntityAlignment
|
|
|
|
blk = backend._doc.blocks.new(name=name)
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
|
const = blk.add_attdef("CONST", insert=(5, -3), text="ACME", dxfattribs={"height": 2.0})
|
|
const.set_placement((5, -3), align=TextEntityAlignment.MIDDLE_CENTER)
|
|
const.dxf.flags |= 2 # ATTRIB_CONST
|
|
assert const.is_const
|
|
|
|
|
|
async def test_explode_keeps_a_constant_attdef_that_has_no_attrib_as_text(backend):
|
|
"""The AutoCAD-authored shape: a constant attribute has no ATTRIB on the
|
|
reference, and ``virtual_entities()`` skips ATTDEF, so its text used to
|
|
vanish with a clean audit. The TEXT lands where ``add_auto_attribs``
|
|
would have put the ATTRIB (mirrored + rotated reference)."""
|
|
_define_block_with_constant_attdef(backend)
|
|
ref = backend._msp().add_blockref(
|
|
"TBC", (20, 10), dxfattribs={"xscale": -1.0, "yscale": 1.0, "rotation": 30.0}
|
|
)
|
|
ref.add_auto_attribs({"TAG": "P-101"})
|
|
expected = {a.dxf.tag: a.dxfattribs() for a in ref.attribs}["CONST"]
|
|
ref.delete_attrib("CONST") # AutoCAD writes no ATTRIB for a constant attribute
|
|
assert [a.dxf.tag for a in ref.attribs] == ["TAG"], "the premise"
|
|
|
|
result = await backend.block_explode(ref.dxf.handle)
|
|
|
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
|
|
texts = {}
|
|
for handle in result["attribute_texts"]:
|
|
raw = backend._doc.entitydb[handle]
|
|
assert raw.dxftype() == "TEXT"
|
|
texts[raw.dxf.text] = raw
|
|
assert set(texts) == {"P-101", "ACME"}
|
|
acme = texts["ACME"]
|
|
for key in ("insert", "align_point", "extrusion"):
|
|
assert tuple(acme.dxf.get(key)) == pytest.approx(tuple(expected[key])), key
|
|
assert acme.dxf.rotation == pytest.approx(expected["rotation"])
|
|
assert acme.dxf.height == expected["height"] == 2.0
|
|
assert acme.get_align_enum().name == "MIDDLE_CENTER"
|
|
assert acme.dxf.invisible == 0
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_does_not_duplicate_a_constant_attdef_that_already_has_an_attrib(backend):
|
|
"""The MCP-authored shape: ``block_insert`` attaches an ATTRIB for the
|
|
constant ATTDEF too, so it is converted once, not once per source."""
|
|
_define_block_with_constant_attdef(backend)
|
|
ref = await backend.block_insert("TBC", 20, 10, attributes={"TAG": "P-101"})
|
|
raw_ref = backend._doc.entitydb[ref.handle]
|
|
assert sorted(a.dxf.tag for a in raw_ref.attribs) == ["CONST", "TAG"], "the premise"
|
|
|
|
result = await backend.block_explode(ref.handle)
|
|
|
|
values = sorted(backend._doc.entitydb[h].dxf.text for h in result["attribute_texts"])
|
|
assert values == ["ACME", "P-101"]
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_of_a_mirrored_reference_keeps_the_tag_where_the_attrib_was(backend):
|
|
"""An ATTRIB is an OCS entity. Mirroring reflects it to extrusion
|
|
``(0, 0, -1)`` while the INSERT keeps +Z with ``yscale=-1``; the TEXT
|
|
that replaces it must carry the frame, or it lands on the other side
|
|
of the mirror axis with a clean audit."""
|
|
from backends.ocs import to_wcs_2d
|
|
|
|
blk = backend._doc.blocks.new(name="SYM")
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_attdef("TAG", insert=(5, 3), text="", dxfattribs={"height": 2.5})
|
|
ref = await backend.block_insert("SYM", 20, 10, attributes={"TAG": "P-101"})
|
|
mirrored = await backend.entity_mirror(ref.handle, 0, 0, 0, 1, delete_original=True)
|
|
raw = backend._doc.entitydb[mirrored.handle]
|
|
(attrib,) = raw.attribs
|
|
assert tuple(attrib.dxf.extrusion) == (0.0, 0.0, -1.0), "the premise: a reflected ATTRIB"
|
|
expected = to_wcs_2d(attrib.dxf.extrusion, *attrib.dxf.insert)
|
|
assert expected == pytest.approx([-25.0, 13.0])
|
|
|
|
result = await backend.block_explode(mirrored.handle)
|
|
|
|
(line_handle,) = result["inserted_handles"]
|
|
(text_handle,) = result["attribute_texts"]
|
|
line = await backend.entity_get(line_handle)
|
|
text = await backend.entity_get(text_handle)
|
|
assert text.properties["insertion"] == pytest.approx(expected)
|
|
assert text.properties["insertion"][0] < 0 and line.properties["start"][0] < 0, (
|
|
"tag and geometry stay on the same side of the mirror axis"
|
|
)
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_writes_into_the_inserts_owner_layout_not_the_current_one(backend):
|
|
_define_tagged_block(backend)
|
|
await backend.layout_create("Sheet")
|
|
await backend.layout_set_current("Sheet")
|
|
ref = await backend.block_insert("TB", 100, 50, attributes={"TAG": "GEAR-01"})
|
|
await backend.layout_set_current("Model")
|
|
model_before = len(list(backend._doc.modelspace()))
|
|
|
|
result = await backend.block_explode(ref.handle)
|
|
|
|
assert result["ok"] is True
|
|
sheet = backend._doc.layouts.get("Sheet")
|
|
assert sorted(e.dxftype() for e in sheet) == ["LINE", "TEXT"]
|
|
assert len(list(backend._doc.modelspace())) == model_before, "model space untouched"
|
|
for handle in result["inserted_handles"] + result["attribute_texts"]:
|
|
assert backend._doc.entitydb[handle].get_layout().name == "Sheet"
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
await backend.entity_get(ref.handle)
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_refuses_a_nested_reference_before_writing(backend):
|
|
_define_tagged_block(backend)
|
|
outer = backend._doc.blocks.new(name="OUTER")
|
|
nested = outer.add_blockref("TB", (0, 0))
|
|
before = (len(list(backend._doc.modelspace())), len(list(outer)))
|
|
with pytest.raises(RuntimeError, match="nested inside block definition 'OUTER'"):
|
|
await backend.block_explode(nested.dxf.handle)
|
|
assert (len(list(backend._doc.modelspace())), len(list(outer))) == before
|
|
assert backend._doc.entitydb[nested.dxf.handle].is_alive
|
|
|
|
|
|
# ── Task 2 (review): multi-line attributes, xrefs and MINSERTs ───────────────
|
|
|
|
|
|
def _embed_multi_line(attrib, lines: list[str], insert, char_height: float = 2.5):
|
|
"""Give ``attrib`` (ATTRIB or ATTDEF) an embedded MTEXT the way ezdxf's
|
|
``embed_mtext`` does; ``dxf.text`` then holds only the first line."""
|
|
from ezdxf.entities import MText
|
|
|
|
mtext = MText.new(dxfattribs={"char_height": char_height, "insert": insert})
|
|
mtext.text = "\\P".join(lines)
|
|
attrib.embed_mtext(mtext)
|
|
assert attrib.has_embedded_mtext_entity and attrib.dxf.text == lines[0], "the premise"
|
|
|
|
|
|
async def test_explode_keeps_a_multi_line_attrib_as_mtext_with_every_line(backend):
|
|
"""A multi-line ATTRIB carries its content in an embedded MTEXT; ``dxf.text``
|
|
is only the first line (ezdxf) or ``Line1\\PLine2`` (AutoCAD), so a TEXT
|
|
either drops the other lines or renders literal ``\\P``. BURST emits an
|
|
MTEXT for a multi-line attribute."""
|
|
blk = backend._doc.blocks.new(name="MLB")
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_attdef("NOTE", insert=(0, 3), text="x", dxfattribs={"height": 2.5})
|
|
blk.add_attdef("HIDDEN", insert=(0, -3), text="x", dxfattribs={"height": 2.0})
|
|
ref = await backend.block_insert(
|
|
"MLB",
|
|
10,
|
|
10,
|
|
scale_x=2.0,
|
|
scale_y=2.0,
|
|
rotation=30.0,
|
|
attributes={"NOTE": "line one", "HIDDEN": "h"},
|
|
)
|
|
raw_ref = backend._doc.entitydb[ref.handle]
|
|
attribs = {a.dxf.tag: a for a in raw_ref.attribs}
|
|
lines = ["LINE ONE", "LINE TWO", "LINE THREE"]
|
|
_embed_multi_line(attribs["NOTE"], lines, attribs["NOTE"].dxf.insert, char_height=5.0)
|
|
attribs["NOTE"].dxf.layer = "PID-TAG"
|
|
_embed_multi_line(attribs["HIDDEN"], ["H1", "H2"], attribs["HIDDEN"].dxf.insert)
|
|
attribs["HIDDEN"].is_invisible = True
|
|
expected = attribs["NOTE"].virtual_mtext_entity()
|
|
|
|
result = await backend.block_explode(ref.handle)
|
|
|
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
|
|
assert len(result["attribute_texts"]) == 2
|
|
by_text = {}
|
|
for handle in result["attribute_texts"]:
|
|
raw = backend._doc.entitydb[handle]
|
|
assert raw.dxftype() == "MTEXT", "a multi-line attribute bursts into an MTEXT"
|
|
by_text[raw.text] = raw
|
|
note = by_text["\\P".join(lines)]
|
|
assert note.plain_text(split=True) == lines
|
|
assert tuple(note.dxf.insert) == pytest.approx(tuple(expected.dxf.insert))
|
|
assert note.dxf.char_height == expected.dxf.char_height == 5.0
|
|
assert note.dxf.attachment_point == expected.dxf.attachment_point
|
|
assert note.dxf.layer == "PID-TAG", "the ATTRIB's graphic properties ride along"
|
|
assert note.dxf.invisible == 0
|
|
assert note.get_layout() is backend._doc.modelspace()
|
|
assert by_text["H1\\PH2"].dxf.invisible == 1, "an invisible attribute stays invisible"
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
await backend.entity_get(ref.handle)
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_keeps_a_multi_line_constant_attdef_as_mtext(backend):
|
|
"""The constant twin: no ATTRIB on the reference, the value lives in the
|
|
definition's ATTDEF, and that ATTDEF is multi-line."""
|
|
blk = backend._doc.blocks.new(name="MLC")
|
|
blk.add_line((0, 0), (10, 0))
|
|
const = blk.add_attdef("CONST", insert=(5, -3), text="x", dxfattribs={"height": 2.0})
|
|
_embed_multi_line(const, ["ACME", "LTD"], (5, -3, 0), char_height=2.0)
|
|
const.dxf.flags |= 2 # ATTRIB_CONST
|
|
ref = backend._msp().add_blockref("MLC", (20, 10), dxfattribs={"rotation": 90.0})
|
|
assert list(ref.attribs) == [], "the premise: AutoCAD writes no ATTRIB for a constant"
|
|
expected = const.virtual_mtext_entity().transform(ref.matrix44())
|
|
|
|
result = await backend.block_explode(ref.dxf.handle)
|
|
|
|
(handle,) = result["attribute_texts"]
|
|
raw = backend._doc.entitydb[handle]
|
|
assert raw.dxftype() == "MTEXT"
|
|
assert raw.plain_text(split=True) == ["ACME", "LTD"]
|
|
assert tuple(raw.dxf.insert) == pytest.approx(tuple(expected.dxf.insert))
|
|
assert raw.get_rotation() == pytest.approx(90.0)
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_explode_refuses_an_xref_before_writing(backend):
|
|
"""``virtual_entities()`` of an xref INSERT yields nothing (the geometry is
|
|
in the other file), so exploding it deleted the reference with ``ok: True``
|
|
and an empty ``inserted_handles``. AutoCAD refuses to explode an xref."""
|
|
backend._doc.add_xref_def("C:/x/detail.dwg", "XREF_DETAIL")
|
|
xref = backend._msp().add_blockref("XREF_DETAIL", (5, 5))
|
|
before = len(list(backend._msp()))
|
|
with pytest.raises(RuntimeError, match="external reference 'XREF_DETAIL'"):
|
|
await backend.block_explode(xref.dxf.handle)
|
|
assert len(list(backend._msp())) == before
|
|
assert backend._doc.entitydb[xref.dxf.handle].is_alive
|
|
|
|
|
|
async def test_explode_refuses_a_minsert_before_writing(backend):
|
|
"""``virtual_entities()`` resolves only the first cell of a MINSERT grid
|
|
(``multi_insert()`` yields all of them), so a 2x3 grid exploded into one
|
|
cell and five vanished with a clean audit. AutoCAD refuses to explode a
|
|
MINSERT; so does this."""
|
|
cell = backend._doc.blocks.new(name="CELL")
|
|
cell.add_circle((0, 0), 1)
|
|
grid = backend._msp().add_blockref(
|
|
"CELL",
|
|
(0, 0),
|
|
dxfattribs={"row_count": 2, "column_count": 3, "row_spacing": 5, "column_spacing": 5},
|
|
)
|
|
assert grid.mcount == 6, "the premise"
|
|
before = len(list(backend._msp()))
|
|
with pytest.raises(RuntimeError, match=r"MINSERT \(2x3 grid of 'CELL'\)"):
|
|
await backend.block_explode(grid.dxf.handle)
|
|
assert len(list(backend._msp())) == before
|
|
assert backend._doc.entitydb[grid.dxf.handle].is_alive
|
|
|
|
|
|
async def test_explode_of_a_single_cell_grid_is_still_an_ordinary_insert(backend):
|
|
"""A ``row_count``/``column_count`` of 1 is ``mcount`` 1 -- a plain INSERT
|
|
wearing MINSERT fields -- and must still explode."""
|
|
cell = backend._doc.blocks.new(name="CELL1")
|
|
cell.add_circle((0, 0), 1)
|
|
single = backend._msp().add_blockref(
|
|
"CELL1",
|
|
(0, 0),
|
|
dxfattribs={"row_count": 1, "column_count": 1, "row_spacing": 5, "column_spacing": 5},
|
|
)
|
|
assert single.mcount == 1
|
|
result = await backend.block_explode(single.dxf.handle)
|
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["CIRCLE"]
|
|
|
|
|
|
async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com):
|
|
"""``TextString`` of a multi-line attribute is one flattened string; the
|
|
content is ``MTextAttributeContent`` and the box ``MTextBoundaryWidth``.
|
|
The TEXT path would render ``\\P`` literally, so BURST adds an MTEXT:
|
|
height, style and frame first, then the angle, then the attachment the
|
|
ATTRIB's alignment names, and the alignment point *last* -- ActiveX's
|
|
``AttachmentPoint`` relocates the anchor (see ``_FakeMText``), and a
|
|
BottomRight note anchored before it landed two lines low and a box width
|
|
right (live, AutoCAD 2026). A TopLeft attribute is the ``AddMText``
|
|
default and could never show that, so this one is BottomRight."""
|
|
backend, document, space = com
|
|
note = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="NOTE",
|
|
TextString="LINE ONE LINE TWO",
|
|
MTextAttribute=True,
|
|
MTextAttributeContent="LINE ONE\\PLINE TWO",
|
|
MTextBoundaryWidth=40.0,
|
|
InsertionPoint=(7.0, 12.7, 0.0),
|
|
Alignment=14, # acAlignmentBottomRight: the MTEXT attachment is the alignment point
|
|
TextAlignmentPoint=(7.0, 15.2, 0.0),
|
|
Height=5.0,
|
|
Rotation=0.5236,
|
|
StyleName="ISO",
|
|
Layer="PID-TAG",
|
|
Color=3,
|
|
Invisible=False,
|
|
)
|
|
plain = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A2",
|
|
TagString="TAG",
|
|
TextString="P-101",
|
|
MTextAttribute=False,
|
|
InsertionPoint=(1.0, 2.0, 0.0),
|
|
Height=2.5,
|
|
Rotation=0.0,
|
|
Layer="0",
|
|
Invisible=False,
|
|
)
|
|
line = _FakeObject("AcDbLine", "L1")
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"6D",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (note, plain),
|
|
Explode=lambda: (line,),
|
|
)
|
|
document.objects["6D"] = ref
|
|
|
|
result = await backend.block_explode("6D")
|
|
|
|
assert result["inserted_handles"] == ["L1"]
|
|
assert result["attribute_texts"] == ["T1", "T2"]
|
|
assert space.calls == [
|
|
("AddMText", ((7.0, 15.2, 0.0), 40.0, "LINE ONE\\PLINE TWO")),
|
|
("AddText", ("P-101", (1.0, 2.0, 0.0), 2.5)),
|
|
]
|
|
assert space.texts[0].ObjectName == "AcDbMText"
|
|
writes = [
|
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
|
for name, value in space.texts[0].writes
|
|
]
|
|
assert writes == [
|
|
("Height", 5.0),
|
|
("StyleName", "ISO"),
|
|
("Rotation", 0.5236),
|
|
("AttachmentPoint", 9), # acAttachmentPointBottomRight
|
|
("InsertionPoint", (7.0, 15.2, 0.0)), # re-asserted after the attachment moved it
|
|
("Layer", "PID-TAG"),
|
|
("Color", 3),
|
|
]
|
|
assert tuple(space.texts[0].InsertionPoint.value) == (7.0, 15.2, 0.0), (
|
|
"the MTEXT ends up anchored at the ATTRIB's alignment point"
|
|
)
|
|
assert ref.deleted is True
|
|
|
|
|
|
async def test_com_explode_multi_line_attrib_takes_the_frame_before_the_angle(com):
|
|
backend, document, space = com
|
|
note = _FakeObject(
|
|
"AcDbAttribute",
|
|
"A1",
|
|
TagString="NOTE",
|
|
TextString="A B",
|
|
MTextAttribute=True,
|
|
MTextAttributeContent="A\\PB",
|
|
InsertionPoint=(-25.0, 13.0, 0.0),
|
|
Alignment=0,
|
|
Normal=(0.0, 0.0, -1.0),
|
|
Height=2.5,
|
|
Rotation=3.1416,
|
|
Layer="0",
|
|
Invisible=True,
|
|
)
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"7E",
|
|
Name="TB",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (note,),
|
|
Explode=lambda: (),
|
|
)
|
|
document.objects["7E"] = ref
|
|
|
|
result = await backend.block_explode("7E")
|
|
|
|
assert result["attribute_texts"] == ["T1"]
|
|
assert space.calls == [("AddMText", ((-25.0, 13.0, 0.0), 0.0, "A\\PB"))], (
|
|
"no MTextBoundaryWidth exposed: a zero (unbounded) box"
|
|
)
|
|
writes = [
|
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
|
for name, value in space.texts[0].writes
|
|
]
|
|
assert writes == [
|
|
("Height", 2.5),
|
|
("Normal", (0.0, 0.0, -1.0)),
|
|
("Rotation", 3.1416),
|
|
("AttachmentPoint", 7), # acAttachmentPointBottomLeft for a baseline-left ATTRIB
|
|
("InsertionPoint", (-25.0, 13.0, 0.0)), # last: the frame AND the attachment moved it
|
|
("Layer", "0"),
|
|
("Visible", False),
|
|
]
|
|
assert tuple(space.texts[0].InsertionPoint.value) == (-25.0, 13.0, 0.0)
|
|
|
|
|
|
async def test_com_explode_refuses_an_xref_before_explode(com):
|
|
"""ActiveX ``Explode()`` raises on an xref; the headless engine used to
|
|
delete it. Both name the refusal before anything is dispatched."""
|
|
backend, document, space = com
|
|
document.Blocks.blocks["XREF_DETAIL"] = _FakeBlock("XREF_DETAIL", is_xref=True)
|
|
dispatched = []
|
|
|
|
def _explode():
|
|
dispatched.append("Explode")
|
|
return ()
|
|
|
|
ref = _FakeObject(
|
|
"AcDbBlockReference",
|
|
"8A",
|
|
Name="XREF_DETAIL",
|
|
OwnerID=1,
|
|
GetAttributes=lambda: (),
|
|
Explode=_explode,
|
|
)
|
|
document.objects["8A"] = ref
|
|
with pytest.raises(RuntimeError, match="external reference 'XREF_DETAIL'"):
|
|
await backend.block_explode("8A")
|
|
assert dispatched == [] and space.calls == [] and ref.deleted is False
|
|
|
|
|
|
async def test_com_explode_refuses_a_minsert_by_name_before_explode(com):
|
|
"""A MINSERT is ``AcDbMInsertBlock``; it used to fail the INSERT check with
|
|
the misleading 'not a block reference'."""
|
|
backend, document, space = com
|
|
dispatched = []
|
|
|
|
def _explode():
|
|
dispatched.append("Explode")
|
|
return ()
|
|
|
|
grid = _FakeObject(
|
|
"AcDbMInsertBlock",
|
|
"9B",
|
|
Name="CELL",
|
|
OwnerID=1,
|
|
Rows=2,
|
|
Columns=3,
|
|
GetAttributes=lambda: (),
|
|
Explode=_explode,
|
|
)
|
|
document.objects["9B"] = grid
|
|
with pytest.raises(RuntimeError, match=r"MINSERT \(2x3 grid of 'CELL'\)"):
|
|
await backend.block_explode("9B")
|
|
assert dispatched == [] and space.calls == [] and grid.deleted is False
|
|
|
|
|
|
# ── Task 2 (review 2): the multi-line writer, and members ezdxf cannot explode ─
|
|
|
|
|
|
async def _insert_multi_line(backend):
|
|
"""An INSERT of a LINE + ATTDEF block whose ATTRIB is multi-line (embedded
|
|
MTEXT ``OLD ONE\\POLD TWO``), built the way the tests above build one."""
|
|
blk = backend._doc.blocks.new(name="MLW")
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_attdef("NOTE", insert=(0, 3), text="x", dxfattribs={"height": 2.5})
|
|
ref = await backend.block_insert("MLW", 10, 10, rotation=30.0, attributes={"NOTE": "first"})
|
|
attrib = backend._doc.entitydb[ref.handle].attribs[0]
|
|
_embed_multi_line(attrib, ["OLD ONE", "OLD TWO"], attrib.dxf.insert)
|
|
return ref, attrib
|
|
|
|
|
|
async def test_get_attributes_reports_every_line_of_a_multi_line_attrib(backend):
|
|
"""``dxf.text`` of an ezdxf-embedded multi-line ATTRIB is only the first
|
|
line; AutoCAD's ``TextString`` (what the COM engine reports) is the whole
|
|
``Line1\\PLine2`` content, and so is the headless reader now."""
|
|
ref, _ = await _insert_multi_line(backend)
|
|
assert await backend.block_get_attributes(ref.handle) == {"NOTE": "OLD ONE\\POLD TWO"}
|
|
|
|
|
|
async def test_set_attributes_rewrites_the_embedded_mtext_of_a_multi_line_attrib(backend):
|
|
"""The writer used to set only ``dxf.text``, so after an edit the reader
|
|
said ``NEW VALUE`` while the explode burst the pre-edit MTEXT -- and an
|
|
R2010 save (no embedded MTEXT exported) carried a value the exploded
|
|
drawing never showed. One value, on every surface."""
|
|
ref, attrib = await _insert_multi_line(backend)
|
|
keys = ("insert", "align_point", "rotation", "height", "halign", "valign", "style")
|
|
before = {key: attrib.dxf.get(key) for key in keys}
|
|
|
|
result = await backend.block_set_attributes(ref.handle, {"NOTE": "NEW VALUE"})
|
|
|
|
assert result == {"ok": True, "updated_tags": ["NOTE"]}
|
|
assert attrib.has_embedded_mtext_entity, "still a multi-line attribute"
|
|
assert attrib.virtual_mtext_entity().text == "NEW VALUE"
|
|
assert attrib.dxf.text == "NEW VALUE"
|
|
after = {key: attrib.dxf.get(key) for key in keys}
|
|
assert after == before, "rewriting the content does not move the attribute"
|
|
assert await backend.block_get_attributes(ref.handle) == {"NOTE": "NEW VALUE"}
|
|
exploded = await backend.block_explode(ref.handle)
|
|
(handle,) = exploded["attribute_texts"]
|
|
raw = backend._doc.entitydb[handle]
|
|
assert raw.dxftype() == "MTEXT" and raw.text == "NEW VALUE"
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_set_attributes_multi_line_value_keeps_every_line(backend):
|
|
"""A ``\\P`` value on a multi-line attribute is what AutoCAD's
|
|
``TextString`` write takes; every line survives, and the empty value
|
|
empties the attribute rather than leaving the old content behind."""
|
|
ref, attrib = await _insert_multi_line(backend)
|
|
|
|
await backend.block_set_attributes(ref.handle, {"NOTE": "NEW ONE\\PNEW TWO\\PNEW THREE"})
|
|
|
|
mtext = attrib.virtual_mtext_entity()
|
|
assert mtext.plain_text(split=True) == ["NEW ONE", "NEW TWO", "NEW THREE"]
|
|
assert attrib.dxf.text == "NEW ONE\\PNEW TWO\\PNEW THREE", (
|
|
"group 1 carries the whole value, AutoCAD's own convention -- ezdxf's "
|
|
"first-line mirror is what an R2010 save would keep"
|
|
)
|
|
assert await backend.block_get_attributes(ref.handle) == {
|
|
"NOTE": "NEW ONE\\PNEW TWO\\PNEW THREE"
|
|
}
|
|
|
|
await backend.block_set_attributes(ref.handle, {"NOTE": ""})
|
|
assert await backend.block_get_attributes(ref.handle) == {"NOTE": ""}
|
|
assert attrib.virtual_mtext_entity().text == "" and attrib.dxf.text == ""
|
|
|
|
exploded = await backend.block_explode(ref.handle)
|
|
(handle,) = exploded["attribute_texts"]
|
|
assert backend._doc.entitydb[handle].text == ""
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_set_attributes_on_a_multi_line_attrib_survives_an_r2010_round_trip(
|
|
backend, tmp_path
|
|
):
|
|
"""The default document is R2010, where ezdxf exports no embedded MTEXT:
|
|
the saved file carries only ``dxf.text``. ``set_mtext`` mirrors just the
|
|
first line there, so a ``\\P`` value once reloaded as ``NEW ONE`` while the
|
|
reader had promised three lines. The whole value goes into ``dxf.text``
|
|
(AutoCAD's own convention), so the reloaded drawing keeps every line."""
|
|
ref, _ = await _insert_multi_line(backend)
|
|
assert backend._doc.dxfversion == "AC1024", "the premise"
|
|
value = "NEW ONE\\PNEW TWO\\PNEW THREE"
|
|
await backend.block_set_attributes(ref.handle, {"NOTE": value})
|
|
assert await backend.block_get_attributes(ref.handle) == {"NOTE": value}
|
|
path = tmp_path / "ml.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
await backend.drawing_open(str(path))
|
|
|
|
reloaded = next(e for e in backend._msp() if e.dxftype() == "INSERT")
|
|
assert not reloaded.attribs[0].has_embedded_mtext_entity, "R2010 drops the embedded MTEXT"
|
|
assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": value}, (
|
|
"the file carries what the reader promised before the save"
|
|
)
|
|
exploded = await backend.block_explode(reloaded.dxf.handle)
|
|
(handle,) = exploded["attribute_texts"]
|
|
raw = backend._doc.entitydb[handle]
|
|
assert raw.dxftype() == "TEXT" and raw.dxf.text == value
|
|
|
|
|
|
async def test_set_attributes_multi_line_value_survives_an_r2018_round_trip(backend, tmp_path):
|
|
"""On R2018 the embedded MTEXT is exported too: both surfaces reload with
|
|
every line and the reader still prefers the MTEXT."""
|
|
ref, _ = await _insert_multi_line(backend)
|
|
backend._doc.dxfversion = "AC1032"
|
|
value = "NEW ONE\\PNEW TWO"
|
|
await backend.block_set_attributes(ref.handle, {"NOTE": value})
|
|
path = tmp_path / "ml2018.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
await backend.drawing_open(str(path))
|
|
|
|
reloaded = next(e for e in backend._msp() if e.dxftype() == "INSERT")
|
|
attrib = reloaded.attribs[0]
|
|
assert attrib.has_embedded_mtext_entity, "R2018 keeps the embedded MTEXT"
|
|
assert attrib.virtual_mtext_entity().plain_text(split=True) == ["NEW ONE", "NEW TWO"]
|
|
assert attrib.dxf.text == value
|
|
assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": value}
|
|
assert backend._doc.audit().errors == []
|
|
|
|
|
|
async def test_set_attributes_leaves_a_single_line_attrib_single_line(backend):
|
|
_define_tagged_block(backend, "SL")
|
|
ref = await backend.block_insert("SL", 0, 0, attributes={"TAG": "P-1"})
|
|
await backend.block_set_attributes(ref.handle, {"TAG": "P-2"})
|
|
attrib = backend._doc.entitydb[ref.handle].attribs[0]
|
|
assert not attrib.has_embedded_mtext_entity and attrib.dxf.text == "P-2"
|
|
assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-2"}
|
|
|
|
|
|
async def test_explode_refuses_members_ezdxf_cannot_explode_before_writing(backend):
|
|
"""``virtual_entities()`` drops every member it cannot copy or transform
|
|
(OLE2FRAME, VIEWPORT) with a debug log, and a proxy entity without proxy
|
|
graphics contributes nothing -- so a title block's OLE logo or a vertical
|
|
product's proxy geometry used to be destroyed with ``ok: True`` and a
|
|
clean audit. Refused by member before anything is written."""
|
|
from ezdxf.entities import factory
|
|
|
|
from backends.base import UnsupportedCapabilityError
|
|
|
|
blk = backend._doc.blocks.new(name="PRX")
|
|
blk.add_line((0, 0), (10, 0))
|
|
for dxftype in ("ACAD_PROXY_ENTITY", "OLE2FRAME", "VIEWPORT"):
|
|
blk.add_entity(factory.create_db_entry(dxftype, {}, backend._doc))
|
|
ref = await backend.block_insert("PRX", 0, 0)
|
|
before = [e.dxf.handle for e in backend._msp()]
|
|
|
|
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
|
await backend.block_explode(ref.handle)
|
|
|
|
assert excinfo.value.capability == "explode_opaque_members"
|
|
message = str(excinfo.value)
|
|
for name in ("ACAD_PROXY_ENTITY", "OLE2FRAME", "VIEWPORT", "'PRX'", "com"):
|
|
assert name in message, message
|
|
assert [e.dxf.handle for e in backend._msp()] == before, "nothing written"
|
|
assert backend._doc.entitydb[ref.handle].is_alive
|
|
|
|
|
|
async def test_explode_still_bursts_a_block_every_member_of_which_it_can_explode(backend):
|
|
"""The refusal is per member, not per block: ordinary geometry keeps
|
|
exploding, in definition order, under a uniform scale."""
|
|
blk = backend._doc.blocks.new(name="PLAIN2")
|
|
blk.add_line((0, 0), (10, 0))
|
|
blk.add_circle((5, 5), 1)
|
|
blk.add_hatch().paths.add_polyline_path([(0, 0), (1, 0), (1, 1)])
|
|
ref = await backend.block_insert("PLAIN2", 0, 0, scale_x=2.0, scale_y=2.0)
|
|
result = await backend.block_explode(ref.handle)
|
|
types = [(await backend.entity_get(h)).type for h in result["inserted_handles"]]
|
|
assert types == ["LINE", "CIRCLE", "HATCH"]
|
|
|
|
|
|
async def test_explode_opaque_members_is_declared_on_both_engines():
|
|
from backends.com_backend import ComBackend
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
headless = EzdxfBackend().capabilities().features["explode_opaque_members"]
|
|
assert headless.supported is False and "OLE2FRAME" in (headless.reason or "")
|
|
assert ComBackend().capabilities().features["explode_opaque_members"].supported is True
|