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