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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-18 10:12:56 +03:00
co-authored by Claude Opus 5
parent b464bcdaf5
commit a51c1b6e14
2 changed files with 340 additions and 19 deletions
+108 -19
View File
@@ -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
+232
View File
@@ -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")