fix(task-2): block_explode bursts multi-line attributes as MTEXT and refuses xrefs and MINSERTs on both engines
A multi-line ATTRIB keeps its content in an embedded MTEXT; dxf.text is only the first line (ezdxf) or "Line1\PLine2" (AutoCAD), so the TEXT the explode emitted silently dropped lines or rendered literal \P with a clean audit. ezdxf: `_burst_attrib` adds `virtual_mtext_entity()` (graphic properties and WCS placement already carried; the reference matrix applied for a constant ATTDEF) and marks it invisible like the ATTRIB. COM: `_capture_attrib` reads `MTextAttribute` / `MTextAttributeContent` / `MTextBoundaryWidth` and `_add_mtext_from_capture` goes through `AddMText` (height, style, frame, then Rotation, then the attachment mapped from the ATTRIB's Alignment). Two INSERT kinds `virtual_entities()` cannot explode were destroyed with ok: True: an xref (no virtual entities, reference deleted) and a MINSERT (only the first cell resolved, the rest of the grid gone). Both are now refused by name before any write, on both engines (`block_record.is_xref` / `mcount > 1` headless; `Blocks.Item(Name).IsXRef` / `AcDbMInsertBlock` live, the latter previously failing the INSERT check with the misleading "not a block reference"). Tests: nine new cases in tests/test_track_a_hardening.py (headless multi-line ATTRIB and constant ATTDEF, xref and MINSERT refusals, a single-cell grid that still explodes; COM multi-line capture, frame-before-angle, and both named refusals). The fake ActiveX surface gains AddMText and IsXRef. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
b12fa854c1
commit
6cb7a13c66
+132
-6
@@ -193,6 +193,14 @@ def _msp():
|
||||
return doc.ModelSpace
|
||||
|
||||
|
||||
def _int_member(obj, name: str) -> int | None:
|
||||
"""``int(obj.<name>)`` or ``None`` when the member is absent or not a number."""
|
||||
try:
|
||||
return int(getattr(obj, name))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _owner_layout_block(doc, ent, handle):
|
||||
"""The block table record that owns ``ent``, refused unless it is a layout.
|
||||
|
||||
@@ -3388,15 +3396,52 @@ class ComBackend(AutoCADBackend):
|
||||
out.pop("TextAlignmentPoint", None)
|
||||
return out
|
||||
|
||||
#: ``AcadText.Alignment`` -> ``AcadMText.AttachmentPoint``, the inverse of
|
||||
#: the map ezdxf applies when it embeds an MTEXT into an ATTRIB (the
|
||||
#: attachment becomes ``halign``/``valign`` and both ``insert`` and
|
||||
#: ``align_point`` become the MTEXT insert). Rows: Top 1-3, Middle 4-6,
|
||||
#: Bottom 7-9; the baseline alignments (Left/Center/Right/Aligned/Middle/
|
||||
#: Fit), which an embedded MTEXT never produces, fall to the nearest row.
|
||||
_TEXT_ALIGNMENT_TO_MTEXT_ATTACHMENT = {
|
||||
0: 7, # acAlignmentLeft -> BottomLeft
|
||||
1: 8, # acAlignmentCenter -> BottomCenter
|
||||
2: 9, # acAlignmentRight -> BottomRight
|
||||
3: 7, # acAlignmentAligned -> BottomLeft
|
||||
4: 5, # acAlignmentMiddle -> MiddleCenter
|
||||
5: 7, # acAlignmentFit -> BottomLeft
|
||||
6: 1, # acAlignmentTopLeft
|
||||
7: 2, # acAlignmentTopCenter
|
||||
8: 3, # acAlignmentTopRight
|
||||
9: 4, # acAlignmentMiddleLeft
|
||||
10: 5, # acAlignmentMiddleCenter
|
||||
11: 6, # acAlignmentMiddleRight
|
||||
12: 7, # acAlignmentBottomLeft
|
||||
13: 8, # acAlignmentBottomCenter
|
||||
14: 9, # acAlignmentBottomRight
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _capture_attrib(cls, attr) -> dict:
|
||||
"""Everything a TEXT needs to stand in for ``attr`` (an ATTRIB or ATTDEF)."""
|
||||
"""Everything a TEXT (or MTEXT) needs to stand in for ``attr`` (an
|
||||
ATTRIB or ATTDEF).
|
||||
|
||||
A multi-line attribute (``MTextAttribute`` true) keeps its content in
|
||||
``MTextAttributeContent`` -- ``TextString`` is one flattened string --
|
||||
and its box width in ``MTextBoundaryWidth``; ``mtext`` marks it so the
|
||||
writer builds an MTEXT. An object that does not expose the member is
|
||||
single-line.
|
||||
"""
|
||||
try:
|
||||
invisible = bool(attr.Invisible)
|
||||
except Exception:
|
||||
invisible = False
|
||||
return {
|
||||
try:
|
||||
is_mtext = bool(attr.MTextAttribute)
|
||||
except Exception:
|
||||
is_mtext = False
|
||||
item = {
|
||||
"text": str(attr.TextString),
|
||||
"mtext": is_mtext,
|
||||
"insertion": tuple(attr.InsertionPoint),
|
||||
"height": float(attr.Height),
|
||||
"rotation": float(attr.Rotation),
|
||||
@@ -3404,16 +3449,69 @@ class ComBackend(AutoCADBackend):
|
||||
"invisible": invisible,
|
||||
"members": cls._capture_attrib_text_members(attr),
|
||||
}
|
||||
if is_mtext:
|
||||
try:
|
||||
item["text"] = str(attr.MTextAttributeContent)
|
||||
except Exception as exc:
|
||||
log.debug("MTextAttributeContent unreadable, keeping TextString: %s", exc)
|
||||
try:
|
||||
item["width"] = float(attr.MTextBoundaryWidth)
|
||||
except Exception:
|
||||
item["width"] = 0.0 # unbounded box, as AddMText takes it
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def _add_mtext_from_capture(cls, owner, item: dict):
|
||||
"""``owner.AddMText`` carrying a captured multi-line ATTRIB/ATTDEF.
|
||||
|
||||
The MTEXT's insertion point *is* its attachment anchor, so it is the
|
||||
ATTRIB's ``TextAlignmentPoint`` when the attribute is box-aligned and
|
||||
the ``InsertionPoint`` otherwise (ezdxf writes both from the embedded
|
||||
MTEXT's insert, so they agree). Height, style and frame go on before
|
||||
``Rotation``, the WCS anchor is re-asserted after a frame change, and
|
||||
the attachment is the ATTRIB's ``Alignment`` mapped through
|
||||
``_TEXT_ALIGNMENT_TO_MTEXT_ATTACHMENT``. ``Thickness``, oblique, width
|
||||
factor and the generation flags are TEXT-only members and stay behind.
|
||||
Unit-tested against the fake; not yet executed live.
|
||||
"""
|
||||
members = item["members"]
|
||||
anchor = item["insertion"]
|
||||
if members.get("Alignment", 0) != 0 and "TextAlignmentPoint" in members:
|
||||
anchor = members["TextAlignmentPoint"]
|
||||
point = _apoint(anchor[0], anchor[1], anchor[2] if len(anchor) > 2 else 0.0)
|
||||
mtext = owner.AddMText(point, float(item.get("width", 0.0)), item["text"])
|
||||
mtext.Height = item["height"]
|
||||
if "StyleName" in members:
|
||||
mtext.StyleName = members["StyleName"]
|
||||
if "Normal" in members:
|
||||
value = members["Normal"]
|
||||
mtext.Normal = _apoint(value[0], value[1], value[2] if len(value) > 2 else 0.0)
|
||||
mtext.Rotation = item["rotation"]
|
||||
if "Normal" in members:
|
||||
mtext.InsertionPoint = point # the frame moved the OCS origin; the anchor was WCS
|
||||
mtext.AttachmentPoint = cls._TEXT_ALIGNMENT_TO_MTEXT_ATTACHMENT.get(
|
||||
members.get("Alignment", 0), 1
|
||||
)
|
||||
mtext.Layer = item["layer"]
|
||||
if "Color" in members:
|
||||
mtext.Color = members["Color"]
|
||||
if item["invisible"]:
|
||||
mtext.Visible = False
|
||||
return mtext
|
||||
|
||||
@classmethod
|
||||
def _add_text_from_capture(cls, owner, item: dict):
|
||||
"""``owner.AddText`` carrying a captured ATTRIB/ATTDEF; returns the TEXT.
|
||||
"""``owner.AddText`` carrying a captured ATTRIB/ATTDEF; returns the TEXT
|
||||
(or, for a multi-line capture, the MTEXT ``_add_mtext_from_capture``
|
||||
builds -- a TEXT of ``TextString`` would show ``\\P`` literally).
|
||||
|
||||
Every point or vector goes through ``_apoint`` (``VT_ARRAY|VT_R8``):
|
||||
ActiveX refuses a plain Python tuple (marshalled ``VT_ARRAY|VT_VARIANT``)
|
||||
with ``E_INVALIDARG``, and ``Normal`` is written after the explode and
|
||||
the ATTDEF deletes, so a tuple there tore the drawing.
|
||||
"""
|
||||
if item.get("mtext"):
|
||||
return cls._add_mtext_from_capture(owner, item)
|
||||
ins = item["insertion"]
|
||||
point = _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0)
|
||||
text = owner.AddText(item["text"], point, item["height"])
|
||||
@@ -3496,20 +3594,48 @@ class ComBackend(AutoCADBackend):
|
||||
a ``VT_ARRAY|VT_R8`` VARIANT like every other point in this file; a
|
||||
plain tuple is ``E_INVALIDARG`` live (verified on AutoCAD 2026).
|
||||
|
||||
A *multi-line* attribute (``MTextAttribute`` true) is captured from
|
||||
``MTextAttributeContent`` and written back with ``AddMText`` -- its
|
||||
``TextString`` is one flattened line and a TEXT of it would show the
|
||||
``\\P`` breaks literally -- see ``_add_mtext_from_capture``.
|
||||
|
||||
The TEXTs go into the reference's *owner* block
|
||||
(``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the
|
||||
members; ``HandleToObject`` resolves a handle in any layout, so a title
|
||||
block on a sheet exploded while Model was active used to get its tag
|
||||
text in model space with ``ok: True``. A reference nested inside a
|
||||
block definition (owner ``IsLayout`` false) is refused before the
|
||||
explode is dispatched.
|
||||
text in model space with ``ok: True``. Refused before the explode is
|
||||
dispatched: a reference nested inside a block definition (owner
|
||||
``IsLayout`` false), a MINSERT (``AcDbMInsertBlock``, which used to
|
||||
fail the INSERT check with the misleading "not a block reference"),
|
||||
and an xref (``Blocks.Item(Name).IsXRef``; ActiveX ``Explode()`` raises
|
||||
on one, the headless engine used to delete it) -- the same three
|
||||
refusals the headless engine names.
|
||||
"""
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
ent = doc.HandleToObject(handle)
|
||||
if ent.ObjectName == "AcDbMInsertBlock":
|
||||
rows = _int_member(ent, "Rows")
|
||||
cols = _int_member(ent, "Columns")
|
||||
grid = f"{rows}x{cols} grid" if rows and cols else "grid"
|
||||
raise RuntimeError(
|
||||
f"Entity {handle} is a MINSERT ({grid} of {str(ent.Name)!r}); a "
|
||||
"multi-insert cannot be exploded"
|
||||
)
|
||||
if ent.ObjectName != "AcDbBlockReference":
|
||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||
name = str(ent.Name)
|
||||
try:
|
||||
is_xref = bool(doc.Blocks.Item(name).IsXRef)
|
||||
except Exception as exc:
|
||||
log.debug("IsXRef probe failed for %r, treating as a local block: %s", name, exc)
|
||||
is_xref = False
|
||||
if is_xref:
|
||||
raise RuntimeError(
|
||||
f"Entity {handle} is an external reference {name!r}; an xref cannot "
|
||||
"be exploded (bind it into the drawing first)"
|
||||
)
|
||||
owner = _owner_layout_block(doc, ent, handle)
|
||||
try:
|
||||
attrs = ent.GetAttributes()
|
||||
|
||||
+72
-12
@@ -4575,6 +4575,62 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"""The ``_ATTRIB_TO_TEXT`` members ``attrib`` (ATTRIB or ATTDEF) carries."""
|
||||
return {key: attrib.dxf.get(key) for key in cls._ATTRIB_TO_TEXT if attrib.dxf.hasattr(key)}
|
||||
|
||||
@classmethod
|
||||
def _burst_attrib(cls, layout, attrib, matrix=None):
|
||||
"""Add the entity that stands in for ``attrib`` (ATTRIB or ATTDEF) to
|
||||
``layout`` and return it: a TEXT carrying the value, or -- for a
|
||||
multi-line attribute -- an MTEXT carrying every line.
|
||||
|
||||
A multi-line ATTRIB keeps its content in an embedded MTEXT; its
|
||||
``dxf.text`` is only the first line (ezdxf) or ``Line1\\PLine2...``
|
||||
(AutoCAD), so a TEXT built from it drops the other lines or renders
|
||||
literal ``\\P`` -- with a clean audit. BURST emits an MTEXT there.
|
||||
``virtual_mtext_entity()`` already carries the ATTRIB's graphic
|
||||
properties and its WCS placement (ezdxf transforms the embedded MTEXT
|
||||
with the ATTRIB). ``matrix``, when given, is the reference's matrix,
|
||||
for an ATTDEF still in block coordinates.
|
||||
"""
|
||||
if attrib.has_embedded_mtext_entity:
|
||||
entity = attrib.virtual_mtext_entity()
|
||||
if matrix is not None:
|
||||
entity.transform(matrix)
|
||||
layout.add_entity(entity)
|
||||
else:
|
||||
entity = layout.add_text(attrib.dxf.text, dxfattribs=cls._text_attribs_of(attrib))
|
||||
if matrix is not None:
|
||||
entity.transform(matrix)
|
||||
if attrib.is_invisible:
|
||||
entity.dxf.invisible = 1
|
||||
return entity
|
||||
|
||||
@staticmethod
|
||||
def _refuse_unexplodable_reference(ent, handle: str) -> None:
|
||||
"""Raise before any write for the two INSERT kinds ``virtual_entities()``
|
||||
cannot explode -- both used to be destroyed with ``ok: True``.
|
||||
|
||||
An *xref* INSERT yields no virtual entities (the geometry is in the
|
||||
other file), so the reference was deleted and nothing put in its
|
||||
place; AutoCAD refuses to explode an xref (bind it first). A
|
||||
*MINSERT* (``mcount`` > 1) yields only its first cell -- ``multi_insert()``
|
||||
resolves the grid -- so a 2x3 grid exploded into one cell and five
|
||||
vanished; AutoCAD refuses to explode a MINSERT.
|
||||
"""
|
||||
name = ent.dxf.get("name", "?")
|
||||
block = ent.block()
|
||||
record = getattr(block, "block_record", None)
|
||||
if record is not None and record.is_xref: # covers XREF_OVERLAY too
|
||||
raise RuntimeError(
|
||||
f"Entity {handle} is an external reference {name!r}; an xref cannot "
|
||||
"be exploded (bind it into the drawing first)"
|
||||
)
|
||||
if ent.mcount > 1:
|
||||
rows = ent.dxf.get("row_count", 1)
|
||||
cols = ent.dxf.get("column_count", 1)
|
||||
raise RuntimeError(
|
||||
f"Entity {handle} is a MINSERT ({rows}x{cols} grid of {name!r}); a "
|
||||
"multi-insert cannot be exploded"
|
||||
)
|
||||
|
||||
async def block_explode(self, handle) -> dict:
|
||||
"""Explode an INSERT into its members; ATTRIB values survive as TEXT.
|
||||
|
||||
@@ -4596,19 +4652,29 @@ class EzdxfBackend(AutoCADBackend):
|
||||
``block_insert`` attaches would have (that ATTRIB, when present, is
|
||||
converted above and the definition is not converted twice).
|
||||
|
||||
A *multi-line* attribute (ATTRIB or constant ATTDEF with an embedded
|
||||
MTEXT) becomes an MTEXT carrying every line, as BURST does -- its
|
||||
``dxf.text`` holds only the first line (ezdxf) or the lines joined
|
||||
with literal ``\\P`` (AutoCAD), so a TEXT silently lost content. See
|
||||
``_burst_attrib``.
|
||||
|
||||
The members and the TEXTs go into the INSERT's *owner* layout, not the
|
||||
current one: ``_get_entity`` resolves a handle in any layout, so a title
|
||||
block on a sheet exploded while Model was current used to have its
|
||||
geometry copied into model space and then die on ``delete_entity``
|
||||
with the reference still alive on the sheet. A reference nested inside
|
||||
a block definition is refused before anything is written — exploding
|
||||
it there would redefine the block under every other reference.
|
||||
with the reference still alive on the sheet. Refused before anything
|
||||
is written: a reference nested inside a block definition (exploding
|
||||
it there would redefine the block under every other reference), an
|
||||
xref, and a MINSERT -- see ``_refuse_unexplodable_reference`` for the
|
||||
last two, which ``virtual_entities()`` cannot represent and which used
|
||||
to be deleted with ``ok: True``.
|
||||
"""
|
||||
|
||||
def _sync():
|
||||
ent = self._get_entity(handle)
|
||||
if ent.dxftype() != "INSERT":
|
||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||
self._refuse_unexplodable_reference(ent, handle)
|
||||
msp = self._owner_layout_for_explode(ent, handle)
|
||||
inserted = []
|
||||
for sub in ent.virtual_entities():
|
||||
@@ -4619,22 +4685,16 @@ class EzdxfBackend(AutoCADBackend):
|
||||
attached_tags = set()
|
||||
for attrib in ent.attribs:
|
||||
attached_tags.add(attrib.dxf.tag)
|
||||
text = msp.add_text(attrib.dxf.text, dxfattribs=self._text_attribs_of(attrib))
|
||||
if attrib.is_invisible:
|
||||
text.dxf.invisible = 1
|
||||
attribute_texts.append(text.dxf.handle)
|
||||
attribute_texts.append(self._burst_attrib(msp, attrib).dxf.handle)
|
||||
block = ent.block()
|
||||
attdefs = block.attdefs() if block is not None else ()
|
||||
matrix = ent.matrix44()
|
||||
for attdef in attdefs:
|
||||
if not attdef.is_const or attdef.dxf.get("tag") in attached_tags:
|
||||
continue
|
||||
if not attdef.dxf.hasattr("insert"):
|
||||
continue # a structure error; nowhere to place it
|
||||
text = msp.add_text(attdef.dxf.text, dxfattribs=self._text_attribs_of(attdef))
|
||||
text.transform(ent.matrix44())
|
||||
if attdef.is_invisible:
|
||||
text.dxf.invisible = 1
|
||||
attribute_texts.append(text.dxf.handle)
|
||||
attribute_texts.append(self._burst_attrib(msp, attdef, matrix).dxf.handle)
|
||||
msp.delete_entity(ent)
|
||||
self._mark_dirty()
|
||||
return {
|
||||
|
||||
@@ -3031,16 +3031,17 @@ async def block_explode(
|
||||
|
||||
Every attached ATTRIB becomes a TEXT with the same value, placement,
|
||||
height, rotation and layer (an invisible attribute becomes an invisible
|
||||
TEXT), and a constant attribute (which has no ATTRIB) becomes a TEXT of
|
||||
its value too; AutoCAD's plain EXPLODE would keep only the tag-name
|
||||
placeholders and drop the values. Everything lands in the layout that owns the
|
||||
TEXT; a multi-line attribute becomes an MTEXT carrying every line), and
|
||||
a constant attribute (which has no ATTRIB) becomes a TEXT of its value
|
||||
too; AutoCAD's plain EXPLODE would keep only the tag-name placeholders
|
||||
and drop the values. Everything lands in the layout that owns the
|
||||
reference (model space or its paper-space sheet), whichever tab is
|
||||
current. Returns `inserted_handles` (the geometry), `attribute_texts`
|
||||
(one TEXT handle per attribute), `exploded_handle` and `backend`.
|
||||
Refused, with nothing written: a handle that is not a block reference,
|
||||
and a reference nested inside a block definition (explode the outer
|
||||
reference instead). Not undoable except through `drawing_undo` / a
|
||||
transaction.
|
||||
(one TEXT or MTEXT handle per attribute), `exploded_handle` and
|
||||
`backend`. Refused, with nothing written: a handle that is not a block
|
||||
reference, a reference nested inside a block definition (explode the
|
||||
outer reference instead), an external reference (bind it first), and a
|
||||
MINSERT grid. Not undoable except through `drawing_undo` / a transaction.
|
||||
"""
|
||||
await ctx.warning(f"Exploding block reference {handle}")
|
||||
return await _backend(ctx).block_explode(handle)
|
||||
|
||||
@@ -62,9 +62,10 @@ class _FakeObject:
|
||||
|
||||
|
||||
class _FakeBlock:
|
||||
def __init__(self, name: str, is_layout: bool = False):
|
||||
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]] = []
|
||||
|
||||
@@ -143,6 +144,12 @@ class _FakeSpace:
|
||||
self.texts.append(obj)
|
||||
return obj
|
||||
|
||||
def AddMText(self, point, width, text):
|
||||
self.calls.append(("AddMText", (tuple(point.value), width, text)))
|
||||
obj = _FakeObject("AcDbMText", f"T{len(self.calls)}")
|
||||
self.texts.append(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com(monkeypatch):
|
||||
@@ -762,3 +769,309 @@ async def test_explode_refuses_a_nested_reference_before_writing(backend):
|
||||
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, anchored at its alignment point."""
|
||||
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=6, # acAlignmentTopLeft: 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", 1), # acAttachmentPointTopLeft
|
||||
("Layer", "PID-TAG"),
|
||||
("Color", 3),
|
||||
]
|
||||
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),
|
||||
("InsertionPoint", (-25.0, 13.0, 0.0)),
|
||||
("AttachmentPoint", 7), # acAttachmentPointBottomLeft for a baseline-left ATTRIB
|
||||
("Layer", "0"),
|
||||
("Visible", False),
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user