From 6cb7a13c66f04ba12dd04c06993a9e66434b5e5a Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:16:26 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 138 +++++++++++++- backends/ezdxf_backend.py | 84 +++++++-- server.py | 17 +- tests/test_track_a_hardening.py | 315 +++++++++++++++++++++++++++++++- 4 files changed, 527 insertions(+), 27 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 89305e4..6036819 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -193,6 +193,14 @@ def _msp(): return doc.ModelSpace +def _int_member(obj, name: str) -> int | None: + """``int(obj.)`` 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() diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 9906e3a..fd44754 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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 { diff --git a/server.py b/server.py index 4379748..116e9a3 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 837f33a..93d5587 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -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