fix(task-10): $DIMBLK header carries the canonical arrowhead name; MLeaderStyle is authored on COM through the ACAD_MLEADERSTYLE dictionary

Two review findings, both root causes.

1. `_sync_header_to_dimstyle` copied the DIMSTYLE entry's ezdxf-internal
   arrowhead spelling (OBLIQUE) into the DXF-facing $DIMBLK/$DIMBLK1/$DIMBLK2
   header, so the saved file named a block it did not hold (the blocks are
   _OBLIQUE / _DOT) and `system_get_variable("DIMBLK")` answered "OBLIQUE"
   headlessly against "_OBLIQUE" on COM and in the same engine's own
   `dimstyle_list` row. The header write now goes through
   `reported_arrowhead` — the same read-side rule `_dimstyle_value` applies.
   Pinned by header, `system_get_variable`, save/reopen (`ezdxf.readfile`
   header equals the block the file holds) and the Restore-to-Standard
   empty-string case in `test_builtin_arrowheads_render_save_and_report_one_spelling`.

2. The `mleaderstyle` capability refusal rested on a false engine boundary.
   acax25enu.tlb (AutoCAD 2026) exposes IAcadMLeaderStyle with Name,
   ArrowSize, LandingGap, TextHeight, TextStyle, ... — exactly what
   `Dictionaries.Item("ACAD_MLEADERSTYLE").Item(i)` returns — and
   IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle") creates one.
   COM `mleaderstyle_list` now reads the four values off those objects
   (`values_available: True`); COM `mleaderstyle_create` validates every value
   (`validate_mleaderstyle`, shared with ezdxf), checks the clash and the text
   style before AddObject, then writes the four properties. The contract
   method is `@abstractmethod` again, the key is gone from both capability
   maps, the fake ActiveX surface models the real call shape, and the spec
   (§4.2, §8.1, §9) states the ActiveX route.

Live AutoCAD was not running on this machine (GetActiveObject: operation
unavailable; no acad.exe), so the COM route is type-library-verified and
fake-tested, not executed live — scripts/smoke_settings_com.py (Task 24)
owns that.

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-21 16:48:20 +03:00
co-authored by Claude Opus 5
parent 4d246b17b8
commit e204f227b4
8 changed files with 302 additions and 95 deletions
+76 -18
View File
@@ -341,6 +341,39 @@ def _com_font_on_support_path(font_file: str) -> bool:
return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder)
_MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE"
_MLEADERSTYLE_CLASS = "AcDbMLeaderStyle"
def _com_mleaderstyle_dictionary(doc, *, create: bool = False):
"""The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none.
ActiveX exposes no ``MLeaderStyles`` *collection*, but the dictionary's
items are full ``IAcadMLeaderStyle`` objects (acax25enu.tlb: ``Name``,
``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and
``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the
documented VBA route — creates one. Every drawing AutoCAD makes carries
the dictionary; ``create`` adds it to a foreign file that does not. The
route is verified against the AutoCAD 2026 type library and fake-tested;
its live execution is scripts/smoke_settings_com.py's job.
"""
try:
return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY)
except Exception:
if not create:
return None
return doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY)
def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]:
"""``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key."""
rows = []
for index in range(dictionary.Count):
style = dictionary.Item(index)
rows.append((str(dictionary.GetName(style)), style))
return rows
def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
"""``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused."""
from engineering.standards.textstyles import TEXT_PRESETS
@@ -923,13 +956,6 @@ class ComBackend(AutoCADBackend):
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
"mleaderstyle": FeatureCapability(
False,
reason=(
"no_activex_mleaderstyle_collection;"
"use_leader_create_mleader_per_leader_parameters"
),
),
},
)
@@ -4420,21 +4446,19 @@ class ComBackend(AutoCADBackend):
async def mleaderstyle_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
try:
dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE")
except Exception:
dictionary = _com_mleaderstyle_dictionary(doc)
if dictionary is None:
return []
rows = []
for index in range(dictionary.Count):
for name, style in _com_mleaderstyle_rows(dictionary):
rows.append(
{
"name": str(dictionary.GetName(dictionary.Item(index))),
# ActiveX exposes no MLeaderStyle object: names only.
"arrow_size": None,
"landing_gap": None,
"text_style": None,
"text_height": None,
"values_available": False,
"name": name,
"arrow_size": float(style.ArrowSize),
"landing_gap": float(style.LandingGap),
"text_style": str(style.TextStyle),
"text_height": float(style.TextHeight),
"values_available": True,
}
)
rows.sort(key=lambda row: row["name"].lower())
@@ -4442,6 +4466,40 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
from engineering.standards.mleaderstyles import validate_mleaderstyle
clean = sanitize_symbol_name(name, kind="mleaderstyle")
typed = validate_mleaderstyle(values)
def _sync():
doc = _acad_doc()
dictionary = _com_mleaderstyle_dictionary(doc, create=True)
wanted = clean.lower()
if any(
existing.lower() == wanted for existing, _ in _com_mleaderstyle_rows(dictionary)
):
raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
# The text style must exist before TextStyle can name it — and a
# name that is neither present nor a preset is refused here,
# before AddObject, so a refusal leaves no half-made style behind.
text_style_name, textstyle_created = _ensure_com_textstyle(
doc, typed["text_style"], refusal_key="text_style"
)
style = dictionary.AddObject(clean, _MLEADERSTYLE_CLASS)
style.ArrowSize = typed["arrow_size"]
style.LandingGap = typed["landing_gap"]
style.TextHeight = typed["text_height"]
style.TextStyle = text_style_name
return {
"ok": True,
"name": clean,
"values": {**typed, "text_style": text_style_name},
"textstyle_created": textstyle_created,
}
return await self._run(_sync)
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod
+14 -21
View File
@@ -14,8 +14,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from backends.capability import capability
class StylesContract(ABC):
@abstractmethod
@@ -115,29 +113,24 @@ class StylesContract(ABC):
async def mleaderstyle_list(self) -> list[dict]:
"""``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.
Headless the values are read from the MLEADERSTYLE object (schema
defaults for an unset field). ActiveX exposes no MLeaderStyle object,
so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE``
dictionary and reports the four values ``None`` with
``values_available: False``.
Both engines report the four values (``values_available: True``).
Headless they are read from the MLEADERSTYLE object (schema defaults
for an unset field); live from the ``AcadMLeaderStyle`` objects the
``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap``
/ ``TextHeight`` in drawing units, ``TextStyle`` a name) — ActiveX
exposes no *collection* property for them, but the dictionary items
are full ``IAcadMLeaderStyle`` objects.
"""
...
# B027 (empty method, no @abstractmethod) is what this pattern looks like
# from the outside: the decorator replaces the `...` body with the refusing
# default, so the source really is empty on purpose.
@capability( # noqa: B027
"mleaderstyle",
reason=(
"ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's "
"per-leader parameters (arrow size, landing, text height) or the headless "
"backend (AUTOCAD_MCP_BACKEND=ezdxf)"
),
)
@abstractmethod
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
"""Create a multileader style from a resolved ``values`` dict
(``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the
text style follows the ``dimstyle_create`` rule (exists or preset,
else refused by name); a clash is refused."""
Both engines: ezdxf on ``doc.mleader_styles``; live through
``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name,
"AcDbMLeaderStyle")`` and the object's property writes. Returns
``{ok, name, values, textstyle_created}``; the text style follows the
``dimstyle_create`` rule (exists or preset, else refused by name); a
clash is refused — before anything is written."""
...
+20 -18
View File
@@ -830,8 +830,18 @@ def _sync_header_to_dimstyle(doc, style) -> None:
`Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no
schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value`
reports.
The arrowheads cross a vocabulary boundary here. The DIMSTYLE entry holds
ezdxf's writer spelling (``OBLIQUE``, see `_write_dimstyle_values`); the
``$DIMBLK`` / ``$DIMBLK1`` / ``$DIMBLK2`` header variables are DXF-facing
and spell a built-in with its underscore (``_OBLIQUE``, what a live seat
stores and what `system_get_variable("DIMBLK")` returns on COM). Copied
raw, the saved file carried ``$DIMBLK "OBLIQUE"`` next to blocks named
``_OBLIQUE`` — a current-override naming a block that does not exist —
and the two engines answered the same read two ways. `reported_arrowhead`
is the same read-side rule `_dimstyle_value` applies.
"""
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST, reported_arrowhead
for var in sorted(DIM_VARIABLE_WHITELIST):
attr = var.lower()
@@ -840,6 +850,8 @@ def _sync_header_to_dimstyle(doc, style) -> None:
value = _dimstyle_schema_default(var)
if value is None and var == "DIMTXSTY":
value = "Standard"
if var in _ARROWHEAD_VARS:
value = reported_arrowhead(value)
key = f"${var}"
if value is None:
try:
@@ -1342,7 +1354,6 @@ class EzdxfBackend(AutoCADBackend):
False, reason="acis_generation_requires_live_autocad"
),
"lisp": FeatureCapability(False, reason="live_com_only"),
"mleaderstyle": FeatureCapability(True, "native"),
},
)
@@ -6079,38 +6090,29 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
from engineering.standards.mleaderstyles import MLEADER_KEYS
from engineering.standards.mleaderstyles import validate_mleaderstyle
from security import sanitize_symbol_name
clean = sanitize_symbol_name(name, kind="mleaderstyle")
missing = [key for key in MLEADER_KEYS if key not in values]
if missing:
raise ValueError(
f"mleaderstyle_create: values is missing {missing}; resolve a preset first"
)
typed = validate_mleaderstyle(values)
def _sync():
doc = self._require_doc()
if doc.mleader_styles.has_entry(clean):
raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
text_style_name, textstyle_created = _ensure_textstyle(
doc, str(values["text_style"]), refusal_key="text_style"
doc, typed["text_style"], refusal_key="text_style"
)
style = doc.mleader_styles.new(clean)
style.dxf.arrow_head_size = float(values["arrow_size"])
style.dxf.landing_gap_size = float(values["landing_gap"])
style.dxf.char_height = float(values["text_height"])
style.dxf.arrow_head_size = typed["arrow_size"]
style.dxf.landing_gap_size = typed["landing_gap"]
style.dxf.char_height = typed["text_height"]
style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle
self._mark_dirty()
return {
"ok": True,
"name": clean,
"values": {
"arrow_size": float(values["arrow_size"]),
"landing_gap": float(values["landing_gap"]),
"text_style": text_style_name,
"text_height": float(values["text_height"]),
},
"values": {**typed, "text_style": text_style_name},
"textstyle_created": textstyle_created,
}