From a51c1b6e14efc1fa8a880526e71b04c590b3677f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Fri, 18 Sep 2026 10:12:56 +0300 Subject: [PATCH] fix(task-2): a moved multi-line attribute keeps one placement and its whole value on the headless engine ezdxf keeps a multi-line ATTRIB's two placement surfaces (the ATTRIB's own insert/align_point and the embedded MTEXT's insert) in step through transform() but not translate(): Insert.translate moves only the ATTRIB's points (1.4.4 attrib.py has no translate). entity_move / entity_copy / entity_array_rectangular therefore left the embedded MTEXT behind, so block_explode burst the note 50 units from its symbol (audit clean), and block_set_attributes / text_find_replace -- which rebuild the MTEXT and let set_mtext re-place the ATTRIB from it -- teleported the attribute back to the old spot while the INSERT stayed put (a regression of e7dafb8/b753a12, which before wrote only dxf.text). - _anchored_mtext(attrib): the virtual MTEXT re-anchored to the ATTRIB's own placement (get_placement() through its OCS, the inverse of ezdxf's _update_location_from_mtext). _burst_attrib and _write_attrib_value read through it, so a drawing whose surfaces already disagree bursts and writes at the ATTRIB's placement instead of the stale MTEXT's. - _translate(ent, ...): translate + settle every multi-line ATTRIB's MTEXT onto the ATTRIB (the set_mtext round trip ezdxf's own transform uses), keeping the whole value in group 1. Used by move, copy, rectangular array. - _transform(ent, m): transform + restore group 1 of every multi-line ATTRIB -- BaseAttrib.transform goes through set_mtext, which mirrors only the first line, so rotate/scale/mirror/polar array/change_space turned 'A\PB' into 'A' on the one surface an R2010 save keeps. - block_insert of a multi-line ATTDEF: add_auto_attribs embeds through embed_mtext with the same first-line mirror; the whole value goes into group 1 the way _write_attrib_value keeps it. Tests: move/copy/rectangular array carry the MTEXT (explode lands on the symbol), set_attributes and find_replace after a move leave the attribute where the move put it, a rotated reference moves both surfaces by the same delta, a stale drawing bursts/writes at the ATTRIB placement, rotate/scale/ mirror/polar and a fresh insert keep the whole value across an R2010 round trip, and a single-line reference is unchanged. 13 fail before the fix, all pass after; full suite 2511 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 127 ++++++++++++++--- tests/test_track_a_hardening.py | 232 ++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 19 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index f03bd61..ab63bb4 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2259,7 +2259,7 @@ class EzdxfBackend(AutoCADBackend): frozen_text = self._format_measurement(entity.get_measurement()) entity.dxf.text = frozen_text - entity.transform(matrix) + self._transform(entity, matrix) source.move_to_layout(entity, target) # Where the entity now sits, expressed in paper coordinates. @@ -3545,7 +3545,7 @@ class EzdxfBackend(AutoCADBackend): async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: def _sync(): ent = self._get_entity(handle) - ent.translate(float(dx), float(dy), float(dz)) + self._translate(ent, float(dx), float(dy), float(dz)) self._mark_dirty() return {"ok": True, "handle": handle} @@ -3556,7 +3556,7 @@ class EzdxfBackend(AutoCADBackend): ent = self._get_entity(handle) copy = ent.copy() self._msp().add_entity(copy) - copy.translate(float(dx), float(dy), float(dz)) + self._translate(copy, float(dx), float(dy), float(dz)) self._mark_dirty() return _entity_info_dxf(copy) @@ -3570,7 +3570,9 @@ class EzdxfBackend(AutoCADBackend): m = Matrix44.z_rotate(math.radians(float(angle_deg))) # Translate to origin, rotate, translate back bx, by = float(base_x), float(base_y) - ent.transform(Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0)) + self._transform( + ent, Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0) + ) self._mark_dirty() return {"ok": True, "handle": handle} @@ -3583,10 +3585,11 @@ class EzdxfBackend(AutoCADBackend): s = float(factor) bx, by = float(base_x), float(base_y) - ent.transform( + self._transform( + ent, Matrix44.translate(-bx, -by, 0) @ Matrix44.scale(s, s, s) - @ Matrix44.translate(bx, by, 0) + @ Matrix44.translate(bx, by, 0), ) self._mark_dirty() return {"ok": True, "handle": handle} @@ -3637,7 +3640,9 @@ class EzdxfBackend(AutoCADBackend): ) ) tx, ty = float(x1), float(y1) - copy.transform(Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0)) + self._transform( + copy, Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0) + ) if delete_original: self._msp().delete_entity(ent) self._mark_dirty() @@ -3744,7 +3749,7 @@ class EzdxfBackend(AutoCADBackend): continue # skip original copy = ent.copy() msp.add_entity(copy) - copy.translate(c * float(col_spacing), r * float(row_spacing), 0) + self._translate(copy, c * float(col_spacing), r * float(row_spacing), 0) results.append(_entity_info_dxf(copy)) self._mark_dirty() return results @@ -3780,7 +3785,7 @@ class EzdxfBackend(AutoCADBackend): @ Matrix44.z_rotate(angle) @ Matrix44.translate(cx, cy, 0) ) - copy.transform(m) + self._transform(copy, m) results.append(_entity_info_dxf(copy)) self._mark_dirty() return results @@ -4541,6 +4546,13 @@ class EzdxfBackend(AutoCADBackend): # `add_auto_attribs` attaches ATTRIBs to this INSERT for every # ATTDEF the definition has; values for tags it lacks are ignored. ref.add_auto_attribs(values) + # A multi-line ATTDEF becomes a multi-line ATTRIB through + # `embed_mtext`, which mirrors only the first line of the value + # into `dxf.text`; the whole value goes there (the convention + # `_write_attrib_value` keeps) so an R2010 save keeps every line. + for attrib in ref.attribs: + if attrib.has_embedded_mtext_entity: + attrib.dxf.text = self._attrib_value(attrib) self._mark_dirty() return _entity_info_dxf(ref) @@ -4600,6 +4612,79 @@ 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)} + @staticmethod + def _anchored_mtext(attrib): + """The embedded MTEXT of a multi-line ``attrib`` (ATTRIB or ATTDEF) as a + virtual entity placed where the attribute itself is. + + A multi-line attribute keeps its placement on two surfaces: the + ATTRIB's own ``insert``/``align_point`` (OCS, the frame of its + ``extrusion``) and the embedded MTEXT's ``insert`` (WCS). ezdxf keeps + them in step through ``transform`` (``BaseAttrib`` overrides it) but + not through ``translate``: ``Insert.translate`` calls ``Text.translate`` + on every ATTRIB, which moves the ATTRIB's two points and nothing else + (ezdxf 1.4.4 ``attrib.py`` has no ``translate``). So after a move the + virtual MTEXT still said where the attribute *was*, ``block_explode`` + burst the note 50 units from its symbol with a clean audit, and a + value write (``set_mtext`` re-places the ATTRIB from the MTEXT) pulled + the attribute back to the old spot while the INSERT stayed put. + + The ATTRIB's placement is the attribute's placement -- it is what + ``entity_get`` reports, what a single-line attribute has, and what + every ezdxf write path maintains -- so the virtual entity is anchored + to it: the inverse of ezdxf's own ``_update_location_from_mtext`` + (``get_placement()`` picks ``insert`` for a LEFT-aligned text and + ``align_point`` otherwise, the same rule the renderer uses). + """ + mtext = attrib.virtual_mtext_entity() + mtext.dxf.insert = attrib.ocs().to_wcs(attrib.get_placement()[1]) + return mtext + + @classmethod + def _settle_attrib_mtext(cls, attrib) -> None: + """Put the embedded MTEXT of ``attrib`` where the ATTRIB now is; a + no-op for a single-line attribute. + + Runs after every ``translate`` of an INSERT (``entity_move``, + ``entity_copy``, ``entity_array_rectangular``). The round trip is the + one ezdxf's ``BaseAttrib.transform`` uses; ``set_mtext`` also mirrors + only the first line into ``dxf.text``, so the value (the whole + ``Line1\\PLine2`` -- see ``_write_attrib_value``) is put back. + """ + if not attrib.has_embedded_mtext_entity: + return + text = attrib.dxf.text + attrib.set_mtext(cls._anchored_mtext(attrib), graphic_properties=False) + attrib.dxf.text = text + + @classmethod + def _translate(cls, ent, dx: float, dy: float, dz: float) -> None: + """``ent.translate(dx, dy, dz)`` that carries a multi-line ATTRIB's + embedded MTEXT along (see ``_anchored_mtext``).""" + ent.translate(dx, dy, dz) + if ent.dxftype() == "INSERT": + for attrib in ent.attribs: + cls._settle_attrib_mtext(attrib) + + @staticmethod + def _transform(ent, m) -> None: + """``ent.transform(m)`` that keeps group 1 of a multi-line ATTRIB whole. + + ezdxf's ``BaseAttrib.transform`` rebuilds the ATTRIB through + ``set_mtext``, which mirrors only the *first line* into ``dxf.text``: + a rotate, scale or mirror after ``block_set_attributes`` turned + ``A\\PB`` back into ``A`` on the one surface an R2010 save keeps, and + the reloaded drawing had lost every line but the first. The content + does not change under a transform, so the value is put back. + """ + if ent.dxftype() != "INSERT": + ent.transform(m) + return + texts = [(a, a.dxf.text) for a in ent.attribs if a.has_embedded_mtext_entity] + ent.transform(m) + for attrib, text in texts: + attrib.dxf.text = text + @classmethod def _burst_attrib(cls, layout, attrib, matrix=None): """Add the entity that stands in for ``attrib`` (ATTRIB or ATTDEF) to @@ -4610,13 +4695,13 @@ class EzdxfBackend(AutoCADBackend): ``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. + ``_anchored_mtext`` carries the ATTRIB's graphic properties and its + placement (the embedded MTEXT's own ``insert`` is stale after a + ``translate``, see there). ``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() + entity = cls._anchored_mtext(attrib) if matrix is not None: entity.transform(matrix) layout.add_entity(entity) @@ -4791,8 +4876,8 @@ class EzdxfBackend(AutoCADBackend): return attrib.virtual_mtext_entity().text return attrib.dxf.text - @staticmethod - def _write_attrib_value(attrib, value: str) -> None: + @classmethod + def _write_attrib_value(cls, attrib, value: str) -> None: """Write ``value`` into ``attrib`` on every surface it has. Setting only ``dxf.text`` on a multi-line ATTRIB left the embedded @@ -4800,9 +4885,13 @@ class EzdxfBackend(AutoCADBackend): value, ``block_explode`` burst the old one, and on an R2010 document (no embedded MTEXT exported) a save/reload carried the new value the exploded drawing never showed. The embedded MTEXT is rebuilt from its - own virtual entity with the new content -- the round trip ezdxf's + virtual entity with the new content -- the round trip ezdxf's ``BaseAttrib.transform`` itself uses -- so the MTEXT and the placement - stay one attribute. + stay one attribute. The virtual entity is anchored to the ATTRIB's + own placement first (``_anchored_mtext``): ``set_mtext`` re-places + the ATTRIB from the MTEXT, and after an ``entity_move`` the embedded + MTEXT still sat where the attribute *was*, so a value edit teleported + the attribute 50 units away from its symbol. ``set_mtext`` then mirrors only the *first line* into ``dxf.text`` (ezdxf's own choice), and below R2018 ezdxf exports no embedded MTEXT, @@ -4814,7 +4903,7 @@ class EzdxfBackend(AutoCADBackend): carries every line on every DXF version. """ if attrib.has_embedded_mtext_entity: - mtext = attrib.virtual_mtext_entity() + mtext = cls._anchored_mtext(attrib) mtext.text = value attrib.set_mtext(mtext, graphic_properties=False) attrib.dxf.text = value diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 9a5b978..ec145c6 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -1391,6 +1391,238 @@ async def test_find_replace_leaves_a_single_line_attrib_single_line(backend): assert await backend.block_get_attributes(ref.handle) == {"TAG": "NEW-1"} +# ── Task 2 (review 4): a moved multi-line attribute keeps one placement ─────── + + +def _placements(attrib) -> tuple[tuple, tuple]: + """(ATTRIB anchor, embedded MTEXT insert), both WCS -- the two surfaces a + multi-line attribute keeps its placement on.""" + anchor = attrib.ocs().to_wcs(attrib.get_placement()[1]) + return tuple(anchor), tuple(attrib.virtual_mtext_entity().dxf.insert) + + +async def _insert_multi_line_plain(backend, name: str = "MLK"): + """The review's repro: unrotated INSERT at (10, 10) of LINE + ATTDEF NOTE, + the ATTRIB made multi-line (``L1\\PL2``) at its own insert (10, 13).""" + blk = backend._doc.blocks.new(name=name) + 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(name, 10, 10, attributes={"NOTE": "first"}) + attrib = backend._doc.entitydb[ref.handle].attribs[0] + _embed_multi_line(attrib, ["L1", "L2"], attrib.dxf.insert) + assert _placements(attrib) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0)), "the premise" + return ref, attrib + + +async def test_move_carries_the_embedded_mtext_of_a_multi_line_attrib(backend): + """``Insert.translate`` moves each ATTRIB's ``insert``/``align_point`` and + nothing else -- ezdxf's ``BaseAttrib`` overrides ``transform`` but not + ``translate`` -- so after ``entity_move`` the embedded MTEXT still sat at + the old place and ``block_explode`` burst it there: the LINE at (60, 60), + the note 50 units away at (10, 13), with a clean audit.""" + ref, attrib = await _insert_multi_line_plain(backend) + + await backend.entity_move(ref.handle, 50, 50) + + assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0)) + assert attrib.dxf.text == "L1", "a move does not touch the value" + result = await backend.block_explode(ref.handle) + (line_handle,) = result["inserted_handles"] + (note_handle,) = result["attribute_texts"] + assert tuple(backend._doc.entitydb[line_handle].dxf.start) == (60.0, 60.0, 0.0) + note = backend._doc.entitydb[note_handle] + assert note.dxftype() == "MTEXT" and note.text == "L1\\PL2" + assert tuple(note.dxf.insert) == (60.0, 63.0, 0.0), "the note moved with its symbol" + assert backend._doc.audit().errors == [] + + +async def test_set_attributes_after_a_move_leaves_the_attribute_where_the_move_put_it(backend): + """``_write_attrib_value`` rebuilt the embedded MTEXT from the stale virtual + entity and ``set_mtext`` re-placed the ATTRIB from it: a value edit after a + move teleported the attribute from (60, 63) back to (10, 13) while the + INSERT stayed at (60, 60). Before Task 2 the writer touched only + ``dxf.text`` and the ATTRIB stayed put, so this was a regression.""" + ref, attrib = await _insert_multi_line_plain(backend) + await backend.entity_move(ref.handle, 50, 50) + + await backend.block_set_attributes(ref.handle, {"NOTE": "NEW1\\PNEW2"}) + + assert tuple(backend._doc.entitydb[ref.handle].dxf.insert) == (60.0, 60.0, 0.0) + assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0)) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": "NEW1\\PNEW2"} + assert attrib.dxf.text == "NEW1\\PNEW2" + + +async def test_find_replace_after_a_move_leaves_the_attribute_where_the_move_put_it(backend): + ref, attrib = await _insert_multi_line_plain(backend) + await backend.entity_move(ref.handle, 50, 50) + + result = await backend.text_find_replace("L1", "X1") + + assert result["replaced"] == 1 + assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0)) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": "X1\\PL2"} + + +async def test_copy_carries_the_embedded_mtext_of_a_multi_line_attrib(backend): + """``entity_copy`` is ``copy()`` + ``translate()``: the copy's ATTRIB moved, + its embedded MTEXT did not, and exploding the copy put the note on the + original.""" + ref, original = await _insert_multi_line_plain(backend) + + copied = await backend.entity_copy(ref.handle, 50, 50) + + attrib = backend._doc.entitydb[copied.handle].attribs[0] + assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0)) + assert _placements(original) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0)), "the original stays" + result = await backend.block_explode(copied.handle) + (note_handle,) = result["attribute_texts"] + note = backend._doc.entitydb[note_handle] + assert note.text == "L1\\PL2" and tuple(note.dxf.insert) == (60.0, 63.0, 0.0) + assert backend._doc.audit().errors == [] + + +async def test_rectangular_array_carries_the_embedded_mtext_of_a_multi_line_attrib(backend): + ref, _ = await _insert_multi_line_plain(backend) + + cells = await backend.entity_array_rectangular(ref.handle, 1, 3, 0, 20) + + for index, cell in enumerate(cells, start=1): + attrib = backend._doc.entitydb[cell.handle].attribs[0] + expected = (10.0 + 20.0 * index, 13.0, 0.0) + assert _placements(attrib) == (expected, expected), f"cell {index}" + + +async def test_move_of_a_rotated_reference_carries_the_embedded_mtext(backend): + """The rotated fixture the other multi-line tests use: the same delta on + both surfaces, whatever the reference's frame.""" + ref, attrib = await _insert_multi_line(backend) + anchor, mtext = _placements(attrib) + assert anchor == pytest.approx(mtext), "the premise" + + await backend.entity_move(ref.handle, 5, -7, 3) + + moved_anchor, moved_mtext = _placements(attrib) + delta = (5.0, -7.0, 3.0) + assert moved_anchor == pytest.approx(tuple(a + d for a, d in zip(anchor, delta, strict=True))) + assert moved_mtext == pytest.approx(moved_anchor) + + +async def test_explode_bursts_a_multi_line_attrib_at_the_attribs_own_placement(backend): + """A drawing whose two surfaces already disagree (written by a tool that, + like ``Insert.translate``, moved only the ATTRIB): the ATTRIB's own + placement is the attribute's placement -- it is what ``entity_get`` reports + and what a single-line attribute has -- so the burst MTEXT goes there, and + a value write keeps it there instead of snapping to the stale MTEXT.""" + ref, attrib = await _insert_multi_line_plain(backend) + attrib.dxf.insert = (40.0, 41.0, 0.0) + attrib.dxf.align_point = (40.0, 41.0, 0.0) + assert _placements(attrib) == ((40.0, 41.0, 0.0), (10.0, 13.0, 0.0)), "the premise" + + await backend.block_set_attributes(ref.handle, {"NOTE": "N1\\PN2"}) + assert _placements(attrib) == ((40.0, 41.0, 0.0), (40.0, 41.0, 0.0)) + + result = await backend.block_explode(ref.handle) + (note_handle,) = result["attribute_texts"] + assert tuple(backend._doc.entitydb[note_handle].dxf.insert) == (40.0, 41.0, 0.0) + + +async def test_explode_of_a_stale_multi_line_attrib_reads_the_attrib_placement(backend): + """The read-only twin: no write before the explode, the burst still lands + on the ATTRIB's placement.""" + ref, attrib = await _insert_multi_line_plain(backend) + attrib.dxf.insert = (40.0, 41.0, 0.0) + attrib.dxf.align_point = (40.0, 41.0, 0.0) + + result = await backend.block_explode(ref.handle) + + (note_handle,) = result["attribute_texts"] + assert tuple(backend._doc.entitydb[note_handle].dxf.insert) == (40.0, 41.0, 0.0) + + +async def _rotated(backend, handle): + await backend.entity_rotate(handle, 0, 0, 90) + return handle + + +async def _scaled(backend, handle): + await backend.entity_scale(handle, 0, 0, 2.0) + return handle + + +async def _mirrored(backend, handle): + """``entity_mirror`` transforms a *copy* and deletes the original.""" + return (await backend.entity_mirror(handle, 0, 0, 0, 1, delete_original=True)).handle + + +async def _polar_cell(backend, handle): + (cell,) = await backend.entity_array_polar(handle, 2, 180, 0, 0) + await backend.entity_delete(handle) + return cell.handle + + +@pytest.mark.parametrize( + "op", [_rotated, _scaled, _mirrored, _polar_cell], ids=["rotate", "scale", "mirror", "polar"] +) +async def test_transform_keeps_the_whole_multi_line_value_in_group_1(backend, op, tmp_path): + """The ``transform`` twin of the move defect: ezdxf's ``BaseAttrib.transform`` + goes through ``set_mtext``, which mirrors only the first line into + ``dxf.text`` -- so a rotate, scale or mirror after ``block_set_attributes`` + turned ``A\\PB`` back into ``A`` on the surface an R2010 save keeps, and + the reloaded drawing had lost every line but the first.""" + ref, attrib = await _insert_multi_line_plain(backend) + await backend.block_set_attributes(ref.handle, {"NOTE": "A\\PB"}) + assert attrib.dxf.text == "A\\PB", "the premise" + + handle = await op(backend, ref.handle) + + attrib = backend._doc.entitydb[handle].attribs[0] + anchor, mtext = _placements(attrib) + assert anchor == pytest.approx(mtext), "the two surfaces still agree on placement" + assert attrib.dxf.text == "A\\PB" + assert await backend.block_get_attributes(handle) == {"NOTE": "A\\PB"} + path = tmp_path / "ml-transformed.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 await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "A\\PB"} + + +async def test_insert_of_a_multi_line_attdef_keeps_the_whole_value_in_group_1(backend, tmp_path): + """The chain the review names: a title block whose ATTDEF is multi-line + (opened from an AutoCAD file), then ``block_insert``. ezdxf's + ``add_auto_attribs`` embeds the MTEXT through ``embed_mtext``, which + mirrors only the first line into ``dxf.text``, so an R2010 save of a + fresh insert kept ``A`` of ``A\\PB``.""" + blk = backend._doc.blocks.new(name="MLT") + blk.add_line((0, 0), (10, 0)) + attdef = blk.add_attdef("NOTE", insert=(0, 3), text="x", dxfattribs={"height": 2.5}) + _embed_multi_line(attdef, ["D1", "D2"], (0, 3)) + + ref = await backend.block_insert("MLT", 10, 10, attributes={"NOTE": "A\\PB"}) + + attrib = backend._doc.entitydb[ref.handle].attribs[0] + assert attrib.has_embedded_mtext_entity and attrib.dxf.text == "A\\PB" + assert _placements(attrib) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0)) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": "A\\PB"} + path = tmp_path / "ml-insert.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 await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "A\\PB"} + + +async def test_move_of_a_single_line_attrib_reference_is_unchanged(backend): + _define_tagged_block(backend, "SLM") + ref = await backend.block_insert("SLM", 0, 0, attributes={"TAG": "P-1"}) + attrib = backend._doc.entitydb[ref.handle].attribs[0] + + await backend.entity_move(ref.handle, 5, 5) + + assert not attrib.has_embedded_mtext_entity + assert tuple(attrib.dxf.insert) == (5.0, 8.0, 0.0) and attrib.dxf.text == "P-1" + + def _boom(*_args): raise RuntimeError("RPC_E_CALL_REJECTED: the application is busy")