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:
Umut CAN
2026-09-17 10:16:26 +03:00
co-authored by Claude Opus 5
parent b12fa854c1
commit 6cb7a13c66
4 changed files with 527 additions and 27 deletions
+132 -6
View File
@@ -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
View File
@@ -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 {