From 5f33613460d4330647f472cbbe09ef717fa0c2de Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 15:24:44 +0300 Subject: [PATCH] fix(task-2): COM burst MTEXT anchors after the attachment; headless multi-line attribute values persist below R2018 Two review findings on the multi-line-attribute paths of block_explode / block_set_attributes. COM: `_add_mtext_from_capture` wrote `InsertionPoint` before `AttachmentPoint`. ActiveX's `AttachmentPoint` keeps the text body where it is and relocates the anchor, so every non-TopLeft attribute was laid out TopLeft-at-anchor with only the label moved (live: a BottomLeft two-line note landed two lines low, BottomRight also a box width right). The anchor is now written last, unconditionally, after the attachment and the frame. Verified live on AutoCAD 2026: every alignment's bbox matches the control and the end-to-end burst MTEXT sits at the ATTRIB's TextAlignmentPoint. The fake MTEXT now relocates `InsertionPoint` on `AttachmentPoint`/`Normal` writes the way ActiveX does, both tests assert where the anchor ended up, and the alignment test uses BottomRight rather than the default TopLeft that could never show the displacement. ezdxf: `_write_attrib_value` relied on `set_mtext`, which mirrors only the first line into `dxf.text`; below R2018 ezdxf exports no embedded MTEXT, so the default R2010 document saved `NEW ONE` where the reader had promised `NEW ONE\PNEW TWO\PNEW THREE`. `dxf.text` now carries the whole value (AutoCAD's own `TextString` convention) after the MTEXT rebuild; the R2010 round-trip test uses a `\P` value, and an R2018 round trip pins both surfaces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 19 +++++--- backends/ezdxf_backend.py | 21 ++++++--- tests/test_track_a_hardening.py | 79 ++++++++++++++++++++++++++++----- 3 files changed, 95 insertions(+), 24 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 953ef9f..808cbf5 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3471,11 +3471,17 @@ class ComBackend(AutoCADBackend): 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. + ``Rotation``, then the attachment (the ATTRIB's ``Alignment`` mapped + through ``_TEXT_ALIGNMENT_TO_MTEXT_ATTACHMENT``), and the WCS anchor + is written *last*: ActiveX ``AttachmentPoint`` keeps the text body + where it is and relocates ``InsertionPoint`` to the new corner, so an + anchor written before it left every non-TopLeft attribute laid out + TopLeft-at-anchor with only the label moved (verified live on AutoCAD + 2026: a BottomLeft two-line note landed two lines low; re-asserting + the anchor after the attachment moves the body). A frame change + (``Normal``) moves the OCS origin the same way, so the one late write + covers both. ``Thickness``, oblique, width factor and the generation + flags are TEXT-only members and stay behind. """ members = item["members"] anchor = item["insertion"] @@ -3490,11 +3496,10 @@ class ComBackend(AutoCADBackend): 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.InsertionPoint = point # after the attachment and the frame: both relocate it mtext.Layer = item["layer"] if "Color" in members: mtext.Color = members["Color"] diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index cec275a..dafcfe8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -4764,8 +4764,9 @@ class EzdxfBackend(AutoCADBackend): """The value an ATTRIB carries, as AutoCAD's ``TextString`` reports it. A multi-line attribute keeps its content in an embedded MTEXT and its - ``dxf.text`` is only the first line when ezdxf authored it (AutoCAD - writes ``Line1\\PLine2``), so the embedded content is the value; a + ``dxf.text`` is only the first line when ezdxf's ``embed_mtext`` / + ``set_mtext`` authored it (AutoCAD, and ``_write_attrib_value``, write + ``Line1\\PLine2``), so the embedded content is the value; a single-line attribute's value is ``dxf.text``. """ if attrib.has_embedded_mtext_entity: @@ -4782,15 +4783,23 @@ class EzdxfBackend(AutoCADBackend): (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 - ``BaseAttrib.transform`` itself uses -- so ``dxf.text`` (first line), - the MTEXT and the placement stay one attribute. + ``BaseAttrib.transform`` itself uses -- so the MTEXT and the placement + stay one attribute. + + ``set_mtext`` then mirrors only the *first line* into ``dxf.text`` + (ezdxf's own choice), and below R2018 ezdxf exports no embedded MTEXT, + so the default (R2010) document saved ``NEW ONE`` where the reader had + promised ``NEW ONE\\PNEW TWO``. ``dxf.text`` is therefore written last + with the whole value: that is AutoCAD's own convention (``TextString`` + of a live multi-line attribute reads ``'LINE ONE\\PLINE TWO'``), the + reader prefers the embedded MTEXT when there is one, and the file + carries every line on every DXF version. """ if attrib.has_embedded_mtext_entity: mtext = attrib.virtual_mtext_entity() mtext.text = value attrib.set_mtext(mtext, graphic_properties=False) - else: - attrib.dxf.text = value + attrib.dxf.text = value async def block_get_attributes(self, handle) -> dict: def _sync(): diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 47f7895..f66ea68 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -61,6 +61,25 @@ class _FakeObject: object.__setattr__(self, "deleted", True) +class _FakeMText(_FakeObject): + """``AddMText``'s MTEXT. ActiveX ``AttachmentPoint`` (and ``Normal``) keep + the text *body* where it is and relocate ``InsertionPoint`` to the new + corner (verified live on AutoCAD 2026: attachment 7 on a two-line note + anchored at (10,10) moved the anchor to (10,-3.33)), so a writer that sets + the anchor first and the attachment second leaves the body TopLeft-at- + anchor. The fake displaces the anchor on those writes -- without recording + a write -- so a test asserts where the anchor *ended up*, not only what + was sent. + """ + + def __setattr__(self, name, value): + super().__setattr__(name, value) + if name in ("AttachmentPoint", "Normal") and hasattr(self, "InsertionPoint"): + x, y, z = self.InsertionPoint.value + moved = types.SimpleNamespace(varianttype="VT_ARRAY|VT_R8", value=[x + 1.0, y - 2.0, z]) + object.__setattr__(self, "InsertionPoint", moved) + + class _FakeBlock: def __init__(self, name: str, is_layout: bool = False, is_xref: bool = False): self.Name = name @@ -146,7 +165,7 @@ class _FakeSpace: def AddMText(self, point, width, text): self.calls.append(("AddMText", (tuple(point.value), width, text))) - obj = _FakeObject("AcDbMText", f"T{len(self.calls)}") + obj = _FakeMText("AcDbMText", f"T{len(self.calls)}", InsertionPoint=point) self.texts.append(obj) return obj @@ -911,7 +930,11 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com): 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.""" + ATTRIB's alignment names, and the alignment point *last* -- ActiveX's + ``AttachmentPoint`` relocates the anchor (see ``_FakeMText``), and a + BottomRight note anchored before it landed two lines low and a box width + right (live, AutoCAD 2026). A TopLeft attribute is the ``AddMText`` + default and could never show that, so this one is BottomRight.""" backend, document, space = com note = _FakeObject( "AcDbAttribute", @@ -922,7 +945,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com): 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 + Alignment=14, # acAlignmentBottomRight: the MTEXT attachment is the alignment point TextAlignmentPoint=(7.0, 15.2, 0.0), Height=5.0, Rotation=0.5236, @@ -971,10 +994,14 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com): ("Height", 5.0), ("StyleName", "ISO"), ("Rotation", 0.5236), - ("AttachmentPoint", 1), # acAttachmentPointTopLeft + ("AttachmentPoint", 9), # acAttachmentPointBottomRight + ("InsertionPoint", (7.0, 15.2, 0.0)), # re-asserted after the attachment moved it ("Layer", "PID-TAG"), ("Color", 3), ] + assert tuple(space.texts[0].InsertionPoint.value) == (7.0, 15.2, 0.0), ( + "the MTEXT ends up anchored at the ATTRIB's alignment point" + ) assert ref.deleted is True @@ -1019,11 +1046,12 @@ async def test_com_explode_multi_line_attrib_takes_the_frame_before_the_angle(co ("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 + ("InsertionPoint", (-25.0, 13.0, 0.0)), # last: the frame AND the attachment moved it ("Layer", "0"), ("Visible", False), ] + assert tuple(space.texts[0].InsertionPoint.value) == (-25.0, 13.0, 0.0) async def test_com_explode_refuses_an_xref_before_explode(com): @@ -1135,7 +1163,10 @@ async def test_set_attributes_multi_line_value_keeps_every_line(backend): mtext = attrib.virtual_mtext_entity() assert mtext.plain_text(split=True) == ["NEW ONE", "NEW TWO", "NEW THREE"] - assert attrib.dxf.text == "NEW ONE", "ezdxf's single-line mirror is the first line" + assert attrib.dxf.text == "NEW ONE\\PNEW TWO\\PNEW THREE", ( + "group 1 carries the whole value, AutoCAD's own convention -- ezdxf's " + "first-line mirror is what an R2010 save would keep" + ) assert await backend.block_get_attributes(ref.handle) == { "NOTE": "NEW ONE\\PNEW TWO\\PNEW THREE" } @@ -1154,22 +1185,48 @@ async def test_set_attributes_on_a_multi_line_attrib_survives_an_r2010_round_tri backend, tmp_path ): """The default document is R2010, where ezdxf exports no embedded MTEXT: - the saved file carries only ``dxf.text``. After the fix that is the - edited value, so the reloaded drawing and the exploded one agree.""" + the saved file carries only ``dxf.text``. ``set_mtext`` mirrors just the + first line there, so a ``\\P`` value once reloaded as ``NEW ONE`` while the + reader had promised three lines. The whole value goes into ``dxf.text`` + (AutoCAD's own convention), so the reloaded drawing keeps every line.""" ref, _ = await _insert_multi_line(backend) assert backend._doc.dxfversion == "AC1024", "the premise" - await backend.block_set_attributes(ref.handle, {"NOTE": "NEW VALUE"}) + value = "NEW ONE\\PNEW TWO\\PNEW THREE" + await backend.block_set_attributes(ref.handle, {"NOTE": value}) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": value} path = tmp_path / "ml.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 not reloaded.attribs[0].has_embedded_mtext_entity, "R2010 drops the embedded MTEXT" - assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "NEW VALUE"} + assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": value}, ( + "the file carries what the reader promised before the save" + ) exploded = await backend.block_explode(reloaded.dxf.handle) (handle,) = exploded["attribute_texts"] raw = backend._doc.entitydb[handle] - assert raw.dxftype() == "TEXT" and raw.dxf.text == "NEW VALUE" + assert raw.dxftype() == "TEXT" and raw.dxf.text == value + + +async def test_set_attributes_multi_line_value_survives_an_r2018_round_trip(backend, tmp_path): + """On R2018 the embedded MTEXT is exported too: both surfaces reload with + every line and the reader still prefers the MTEXT.""" + ref, _ = await _insert_multi_line(backend) + backend._doc.dxfversion = "AC1032" + value = "NEW ONE\\PNEW TWO" + await backend.block_set_attributes(ref.handle, {"NOTE": value}) + path = tmp_path / "ml2018.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") + attrib = reloaded.attribs[0] + assert attrib.has_embedded_mtext_entity, "R2018 keeps the embedded MTEXT" + assert attrib.virtual_mtext_entity().plain_text(split=True) == ["NEW ONE", "NEW TWO"] + assert attrib.dxf.text == value + assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": value} + assert backend._doc.audit().errors == [] async def test_set_attributes_leaves_a_single_line_attrib_single_line(backend):