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) 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]: 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.""" """``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused."""
from engineering.standards.textstyles import TEXT_PRESETS from engineering.standards.textstyles import TEXT_PRESETS
@@ -923,13 +956,6 @@ class ComBackend(AutoCADBackend):
"viewport_render": FeatureCapability(True, "native"), "viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(), "solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"), "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]: async def mleaderstyle_list(self) -> list[dict]:
def _sync(): def _sync():
doc = _acad_doc() doc = _acad_doc()
try: dictionary = _com_mleaderstyle_dictionary(doc)
dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE") if dictionary is None:
except Exception:
return [] return []
rows = [] rows = []
for index in range(dictionary.Count): for name, style in _com_mleaderstyle_rows(dictionary):
rows.append( rows.append(
{ {
"name": str(dictionary.GetName(dictionary.Item(index))), "name": name,
# ActiveX exposes no MLeaderStyle object: names only. "arrow_size": float(style.ArrowSize),
"arrow_size": None, "landing_gap": float(style.LandingGap),
"landing_gap": None, "text_style": str(style.TextStyle),
"text_style": None, "text_height": float(style.TextHeight),
"text_height": None, "values_available": True,
"values_available": False,
} }
) )
rows.sort(key=lambda row: row["name"].lower()) rows.sort(key=lambda row: row["name"].lower())
@@ -4442,6 +4466,40 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) 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 ────────────────────────────────────────────────────────── # ── corner ops ──────────────────────────────────────────────────────────
@staticmethod @staticmethod
+14 -21
View File
@@ -14,8 +14,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from backends.capability import capability
class StylesContract(ABC): class StylesContract(ABC):
@abstractmethod @abstractmethod
@@ -115,29 +113,24 @@ class StylesContract(ABC):
async def mleaderstyle_list(self) -> list[dict]: async def mleaderstyle_list(self) -> list[dict]:
"""``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``. """``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.
Headless the values are read from the MLEADERSTYLE object (schema Both engines report the four values (``values_available: True``).
defaults for an unset field). ActiveX exposes no MLeaderStyle object, Headless they are read from the MLEADERSTYLE object (schema defaults
so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE`` for an unset field); live from the ``AcadMLeaderStyle`` objects the
dictionary and reports the four values ``None`` with ``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap``
``values_available: False``. / ``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 @abstractmethod
# 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)"
),
)
async def mleaderstyle_create(self, name: str, values: dict) -> dict: async def mleaderstyle_create(self, name: str, values: dict) -> dict:
"""Create a multileader style from a resolved ``values`` dict """Create a multileader style from a resolved ``values`` dict
(``engineering.standards.mleaderstyles.resolve_mleaderstyle``). (``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the Both engines: ezdxf on ``doc.mleader_styles``; live through
text style follows the ``dimstyle_create`` rule (exists or preset, ``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name,
else refused by name); a clash is refused.""" "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 `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no
schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value`
reports. 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): for var in sorted(DIM_VARIABLE_WHITELIST):
attr = var.lower() attr = var.lower()
@@ -840,6 +850,8 @@ def _sync_header_to_dimstyle(doc, style) -> None:
value = _dimstyle_schema_default(var) value = _dimstyle_schema_default(var)
if value is None and var == "DIMTXSTY": if value is None and var == "DIMTXSTY":
value = "Standard" value = "Standard"
if var in _ARROWHEAD_VARS:
value = reported_arrowhead(value)
key = f"${var}" key = f"${var}"
if value is None: if value is None:
try: try:
@@ -1342,7 +1354,6 @@ class EzdxfBackend(AutoCADBackend):
False, reason="acis_generation_requires_live_autocad" False, reason="acis_generation_requires_live_autocad"
), ),
"lisp": FeatureCapability(False, reason="live_com_only"), "lisp": FeatureCapability(False, reason="live_com_only"),
"mleaderstyle": FeatureCapability(True, "native"),
}, },
) )
@@ -6079,38 +6090,29 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync) return await self._async(_sync)
async def mleaderstyle_create(self, name: str, values: dict) -> dict: 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 from security import sanitize_symbol_name
clean = sanitize_symbol_name(name, kind="mleaderstyle") clean = sanitize_symbol_name(name, kind="mleaderstyle")
missing = [key for key in MLEADER_KEYS if key not in values] typed = validate_mleaderstyle(values)
if missing:
raise ValueError(
f"mleaderstyle_create: values is missing {missing}; resolve a preset first"
)
def _sync(): def _sync():
doc = self._require_doc() doc = self._require_doc()
if doc.mleader_styles.has_entry(clean): if doc.mleader_styles.has_entry(clean):
raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
text_style_name, textstyle_created = _ensure_textstyle( 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 = doc.mleader_styles.new(clean)
style.dxf.arrow_head_size = float(values["arrow_size"]) style.dxf.arrow_head_size = typed["arrow_size"]
style.dxf.landing_gap_size = float(values["landing_gap"]) style.dxf.landing_gap_size = typed["landing_gap"]
style.dxf.char_height = float(values["text_height"]) style.dxf.char_height = typed["text_height"]
style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle
self._mark_dirty() self._mark_dirty()
return { return {
"ok": True, "ok": True,
"name": clean, "name": clean,
"values": { "values": {**typed, "text_style": text_style_name},
"arrow_size": float(values["arrow_size"]),
"landing_gap": float(values["landing_gap"]),
"text_style": text_style_name,
"text_height": float(values["text_height"]),
},
"textstyle_created": textstyle_created, "textstyle_created": textstyle_created,
} }
@@ -118,7 +118,7 @@ name.
- `dimstyle_modify(name, overrides)` — `changed` reports only variables that actually moved (the `hatch_edit` rule). Dimensions already using the style are listed in `dimensions_using_style` with `rerender_required: true` on the headless engine: AutoCAD re-renders them on the next regen, but a headless DIMENSION carries its rendered block, so the drawing shows the old style until the dimension is redrawn — stated rather than silent. - `dimstyle_modify(name, overrides)` — `changed` reports only variables that actually moved (the `hatch_edit` rule). Dimensions already using the style are listed in `dimensions_using_style` with `rerender_required: true` on the headless engine: AutoCAD re-renders them on the next regen, but a headless DIMENSION carries its rendered block, so the drawing shows the old style until the dimension is redrawn — stated rather than silent.
- `dimstyle_set_current(name)` — `$DIMSTYLE`; the dimension tools then use it (the existing per-dimension override path stays as is). - `dimstyle_set_current(name)` — `$DIMSTYLE`; the dimension tools then use it (the existing per-dimension override path stays as is).
- `textstyle_list()`, `textstyle_create(name, font: "isocp.shx"|"isocpeur.ttf"|"arial.ttf"|"romans.shx"|<file>, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)` — `$TEXTSTYLE`; a font file that is neither a bundled preset nor present in the engine's font path is written but reported `font_resolved: false` (AutoCAD substitutes at open; the headless renderer falls back), never refused, because DXF only stores the name. - `textstyle_list()`, `textstyle_create(name, font: "isocp.shx"|"isocpeur.ttf"|"arial.ttf"|"romans.shx"|<file>, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)` — `$TEXTSTYLE`; a font file that is neither a bundled preset nor present in the engine's font path is written but reported `font_resolved: false` (AutoCAD substitutes at open; the headless renderer falls back), never refused, because DXF only stores the name.
- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height on ezdxf's `doc.mleader_styles`. ActiveX exposes no MLeaderStyle collection, so the COM engine refuses both with `capability: "mleaderstyle"` (the reason names `leader_create_mleader`'s existing per-leader parameters as the route); `mleaderstyle_list` on COM still reads the names from the `ACAD_MLEADERSTYLE` dictionary. - `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height, both engines: ezdxf on `doc.mleader_styles`; COM through the `ACAD_MLEADERSTYLE` dictionary, whose items are `IAcadMLeaderStyle` objects (`ArrowSize` / `LandingGap` / `TextHeight` in drawing units, `TextStyle` a name — verified against the AutoCAD 2026 type library), created with `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")` followed by the property writes. ActiveX has no `MLeaderStyles` *collection* property, which is not an engine boundary — no `mleaderstyle` capability key exists.
- `drawing_apply_standard(standard: "iso"|"ansi", layers: bool = True, units: bool = True)` — one call: the dimstyle preset (created and set current), its text style, `drawing_settings` (units mm, decimal linear and angular units, LTSCALE 1, DIMSCALE 1; the decimal separator comes from the preset) and, with `layers`, the `mech` layer set for both standards (ANSI layer naming is company-specific; the set reuses the mech names with ISO 128 lineweights — only styles and the separator differ between the two). Returns, per item, whether it was created or was already present. - `drawing_apply_standard(standard: "iso"|"ansi", layers: bool = True, units: bool = True)` — one call: the dimstyle preset (created and set current), its text style, `drawing_settings` (units mm, decimal linear and angular units, LTSCALE 1, DIMSCALE 1; the decimal separator comes from the preset) and, with `layers`, the `mech` layer set for both standards (ANSI layer naming is company-specific; the set reuses the mech names with ISO 128 lineweights — only styles and the separator differ between the two). Returns, per item, whether it was created or was already present.
## 5. Settings (SECTION 20 shares these; 3 tools + facade growth) ## 5. Settings (SECTION 20 shares these; 3 tools + facade growth)
@@ -187,9 +187,10 @@ A state is the layer table snapshot `{layer: {on, frozen, locked, color, linetyp
### 8.1 Capability keys (added to **both** maps) ### 8.1 Capability keys (added to **both** maps)
`mleaderstyle` (COM false), `dwgprops` (ezdxf false: summary fields), `dwt_write` `dwgprops` (ezdxf false: summary fields), `dwt_write` (ezdxf false),
(ezdxf false), `live_application`, `preferences`, `interactive_prompt` (ezdxf `live_application`, `preferences`, `interactive_prompt` (ezdxf false).
false). `tests/test_capability_contract.py` enforces both maps. `tests/test_capability_contract.py` enforces both maps. (`mleaderstyle` was
listed here as COM false; the ActiveX route exists — §4.2 — so there is no key.)
### 8.2 Packs, profiles, discovery ### 8.2 Packs, profiles, discovery
@@ -219,7 +220,9 @@ shop-floor synonyms; the discovery golden set gains ≥ 10 queries for them
(A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog (A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog
sysvar has type+meaning+engines. sysvar has type+meaning+engines.
- Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call - Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call
shapes: `DimStyles.Add`, `TextStyles.Add`, `Layout.ConfigName/…`, shapes: `DimStyles.Add`, `TextStyles.Add`,
`Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")`,
`Layout.ConfigName/…`,
`Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`, `Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`,
`UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`, `UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`,
`Utility.GetPoint`). `Utility.GetPoint`).
+6 -1
View File
@@ -23,7 +23,11 @@ from engineering.standards.dimstyles import (
resolve_dimstyle, resolve_dimstyle,
validate_overrides, validate_overrides,
) )
from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle from engineering.standards.mleaderstyles import (
MLEADER_PRESETS,
resolve_mleaderstyle,
validate_mleaderstyle,
)
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
__all__ = [ __all__ = [
@@ -42,6 +46,7 @@ __all__ = [
"resolve_dimstyle", "resolve_dimstyle",
"resolve_font", "resolve_font",
"resolve_mleaderstyle", "resolve_mleaderstyle",
"validate_mleaderstyle",
"validate_overrides", "validate_overrides",
"validate_textstyle", "validate_textstyle",
] ]
+29 -3
View File
@@ -2,8 +2,10 @@
Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows
and lettering; ANSI: 3 mm), so a leader note and a dimension on the same and lettering; ANSI: 3 mm), so a leader note and a dimension on the same
sheet read at one size. Only ezdxf can author an MLEADERSTYLE object; sheet read at one size. Both engines author the style: ezdxf on
ActiveX exposes no collection for it (see ``backends/contracts/styles.py``). ``doc.mleader_styles``, ActiveX through the ``ACAD_MLEADERSTYLE`` dictionary's
``AddObject(name, "AcDbMLeaderStyle")`` and the ``IAcadMLeaderStyle``
properties (see ``backends/contracts/styles.py``).
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +15,7 @@ from typing import Any
from engineering.standards.names import check_name from engineering.standards.names import check_name
__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] __all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle", "validate_mleaderstyle"]
MLEADER_PRESETS: dict[str, dict[str, Any]] = { MLEADER_PRESETS: dict[str, dict[str, Any]] = {
"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
@@ -57,3 +59,27 @@ def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str
else: else:
values[name] = _positive(name, value) values[name] = _positive(name, value)
return values return values
def validate_mleaderstyle(values: Any) -> dict[str, Any]:
"""The four ``MLEADER_KEYS`` of ``values``, typed, or a refusal naming the key.
What both engines' ``mleaderstyle_create`` run before touching a table:
a missing key is ``ValueError`` (``resolve a preset first``), and each
value is checked exactly as :func:`resolve_mleaderstyle` checks an
override — so a hand-built dict is held to the preset's rules too.
Unknown keys are ignored: the row shape is the contract, not the input.
"""
if not isinstance(values, dict):
raise TypeError(f"values: expected a mapping, got {type(values).__name__}")
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"
)
return {
"arrow_size": _positive("arrow_size", values["arrow_size"]),
"landing_gap": _positive("landing_gap", values["landing_gap"], allow_zero=True),
"text_style": check_name("text_style", values["text_style"], what="text style name"),
"text_height": _positive("text_height", values["text_height"]),
}
+26
View File
@@ -30,6 +30,7 @@ from engineering.standards.mleaderstyles import (
MLEADER_KEYS, MLEADER_KEYS,
MLEADER_PRESETS, MLEADER_PRESETS,
resolve_mleaderstyle, resolve_mleaderstyle,
validate_mleaderstyle,
) )
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
@@ -564,3 +565,28 @@ def test_resolve_mleaderstyle_applies_overrides():
def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment): def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment):
with pytest.raises(exc, match=fragment): with pytest.raises(exc, match=fragment):
resolve_mleaderstyle(preset, overrides) resolve_mleaderstyle(preset, overrides)
def test_validate_mleaderstyle_types_the_four_keys_and_refuses_by_name():
"""What both engines' ``mleaderstyle_create`` run before any write: a
hand-built dict is held to the same rules as a preset override."""
assert validate_mleaderstyle(resolve_mleaderstyle("iso")) == MLEADER_PRESETS["iso"]
typed = validate_mleaderstyle(
{"arrow_size": 3, "landing_gap": 0, "text_style": " arial ", "text_height": 2, "extra": 1}
)
assert typed == {
"arrow_size": 3.0,
"landing_gap": 0.0,
"text_style": "arial",
"text_height": 2.0,
}
with pytest.raises(ValueError, match=r"missing \['landing_gap', 'text_style', 'text_height'\]"):
validate_mleaderstyle({"arrow_size": 2.5})
with pytest.raises(ValueError, match="arrow_size"):
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "arrow_size": 0})
with pytest.raises(ValueError, match="text_style"):
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_style": "a|b"})
with pytest.raises(TypeError, match="text_height"):
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_height": "2.5"})
with pytest.raises(TypeError, match="values"):
validate_mleaderstyle(["arrow_size"])
+123 -29
View File
@@ -16,7 +16,6 @@ import ezdxf
import pytest import pytest
import config import config
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default from backends.capability import is_capability_default
from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name
from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle
@@ -162,13 +161,31 @@ async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, t
) )
again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"}) again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"})
assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)" assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)"
# The style is current, so Restore loaded it into the $DIM* header. The
# header is DXF-facing: a built-in is spelled with its underscore there
# (what a live seat stores and what COM's system_get_variable returns),
# never ezdxf's writer spelling the DIMSTYLE entry holds.
header = backend._doc.header
assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("_OBLIQUE", "_DOT", "")
assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE", "the same answer as COM"
path = str(tmp_path / "arrows.dxf") path = str(tmp_path / "arrows.dxf")
await backend.drawing_save_as(path) await backend.drawing_save_as(path)
saved = ezdxf.readfile(path)
assert saved.header["$DIMBLK"] == "_OBLIQUE" and saved.header["$DIMBLK1"] == "_DOT", (
"the file names the block it actually holds, not a block that does not exist"
)
assert "_OBLIQUE" in saved.blocks and "_DOT" in saved.blocks
await backend.drawing_open(path) await backend.drawing_open(path)
rows = {r["name"]: r for r in await backend.dimstyle_list()} rows = {r["name"]: r for r in await backend.dimstyle_list()}
assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling" assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling"
assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE"
reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"}) reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"})
assert reopened["changed"] == {} assert reopened["changed"] == {}
await backend.dimstyle_set_current("Standard")
header = backend._doc.header # drawing_open replaced the document
assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("", "", ""), (
"closed filled is the empty string in the header, as in a fresh drawing"
)
async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend): async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend):
@@ -434,12 +451,20 @@ async def test_mleaderstyle_refusals_write_nothing(backend):
assert not backend._doc.mleader_styles.has_entry("X") assert not backend._doc.mleader_styles.has_entry("X")
async def test_both_capability_maps_declare_mleaderstyle(backend): async def test_mleaderstyle_is_not_a_capability_boundary_on_either_engine(backend):
"""The v1.6 plan declared `mleaderstyle` COM-unsupported ("ActiveX exposes
no MLeaderStyle collection"). The type library says otherwise: the
ACAD_MLEADERSTYLE dictionary items are IAcadMLeaderStyle objects and
AddObject(name, "AcDbMLeaderStyle") creates one. A refusal resting on a
false premise is exactly what CLAUDE.md forbids `@capability` for, so
neither map declares the key and neither engine inherits a default."""
from backends.com_backend import ComBackend from backends.com_backend import ComBackend
assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True assert "mleaderstyle" not in backend.capabilities().to_dict()["features"]
com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"] assert "mleaderstyle" not in ComBackend().capabilities().to_dict()["features"]
assert com["supported"] is False and "leader_create_mleader" in com["reason"] for engine in (type(backend), ComBackend):
assert not is_capability_default(engine, "mleaderstyle_create")
assert not is_capability_default(engine, "mleaderstyle_list")
# ── live engine, against a fake ActiveX surface ────────────────────────────── # ── live engine, against a fake ActiveX surface ──────────────────────────────
@@ -506,9 +531,32 @@ class _FakeLayouts:
return self.layouts[index] return self.layouts[index]
class _FakeMLeaderStyle:
"""An AcadMLeaderStyle: the properties acax25enu.tlb's IAcadMLeaderStyle
exposes that the backend reads or writes (doubles in drawing units, the
text style by name), with AutoCAD's defaults for a fresh style."""
PROPERTIES = ("ArrowSize", "LandingGap", "TextHeight", "TextStyle")
def __init__(self, name, calls):
self.Name = name
self.ObjectName = "AcDbMLeaderStyle"
self.ArrowSize, self.LandingGap, self.TextHeight, self.TextStyle = 4.0, 2.0, 4.0, "Standard"
self._calls = calls # last: property writes are recorded from here on
def __setattr__(self, key, value):
if key in self.PROPERTIES and "_calls" in vars(self):
self._calls.append(("MLeaderStyle." + key, self.Name, value))
super().__setattr__(key, value)
class _FakeDictionary: class _FakeDictionary:
def __init__(self, *names): """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects
self.objects = [types.SimpleNamespace(mleader_name=name) for name in names] and AddObject(keyword, class_name), the documented VBA route."""
def __init__(self, calls, *names):
self._calls = calls
self.objects = [_FakeMLeaderStyle(name, calls) for name in names]
@property @property
def Count(self): def Count(self):
@@ -518,7 +566,15 @@ class _FakeDictionary:
return self.objects[index] return self.objects[index]
def GetName(self, obj): def GetName(self, obj):
return obj.mleader_name return obj.Name
def AddObject(self, keyword, class_name):
self._calls.append(("Dictionary.AddObject", keyword, class_name))
if class_name != "AcDbMLeaderStyle":
raise RuntimeError(f"cannot create {class_name} here")
style = _FakeMLeaderStyle(keyword, self._calls)
self.objects.append(style)
return style
class _FakeDictionaries: class _FakeDictionaries:
@@ -570,7 +626,7 @@ class _FakeDocument:
) )
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
self.Dictionaries = _FakeDictionaries( self.Dictionaries = _FakeDictionaries(
{"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")} {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")}
) )
def GetVariable(self, name): def GetVariable(self, name):
@@ -751,36 +807,74 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True} assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend): async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend):
backend, document = com_backend backend, document = com_backend
dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE")
dictionary.objects[1].ArrowSize = 3.0
dictionary.objects[1].TextStyle = "ISOCP"
document.calls.clear() # fixture setup, not backend calls
rows = await backend.mleaderstyle_list() rows = await backend.mleaderstyle_list()
assert rows == [ assert rows == [
{ {
"name": "Annotative", "name": "Annotative",
"arrow_size": None, "arrow_size": 3.0,
"landing_gap": None, "landing_gap": 2.0,
"text_style": None, "text_style": "ISOCP",
"text_height": None, "text_height": 4.0,
"values_available": False, "values_available": True,
}, },
{ {
"name": "Standard", "name": "Standard",
"arrow_size": None, "arrow_size": 4.0,
"landing_gap": None, "landing_gap": 2.0,
"text_style": None, "text_style": "Standard",
"text_height": None, "text_height": 4.0,
"values_available": False, "values_available": True,
}, },
] ]
assert document.calls == [], "a list reads; it writes nothing"
async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend): async def test_com_mleaderstyle_create_adds_the_object_then_writes_its_properties(com_backend):
from backends.com_backend import ComBackend
backend, document = com_backend backend, document = com_backend
assert is_capability_default(ComBackend, "mleaderstyle_create") result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
with pytest.raises(UnsupportedCapabilityError) as excinfo: assert result == {
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) "ok": True,
assert excinfo.value.capability == "mleaderstyle" "name": "ISO",
assert "leader_create_mleader" in str(excinfo.value) "values": {
assert document.calls == [] "arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5,
},
"textstyle_created": True,
}
names = [call[0] for call in document.calls]
assert names.index("TextStyles.Add") < names.index("Dictionary.AddObject"), (
"the text style exists before TextStyle names it"
)
assert ("Dictionary.AddObject", "ISO", "AcDbMLeaderStyle") in document.calls
writes = [call for call in document.calls if call[0].startswith("MLeaderStyle.")]
assert writes == [
("MLeaderStyle.ArrowSize", "ISO", 2.5),
("MLeaderStyle.LandingGap", "ISO", 1.0),
("MLeaderStyle.TextHeight", "ISO", 2.5),
("MLeaderStyle.TextStyle", "ISO", "ISOCP"),
]
rows = {row["name"]: row for row in await backend.mleaderstyle_list()}
assert rows["ISO"] == {**result["values"], "name": "ISO", "values_available": True}
async def test_com_mleaderstyle_refusals_write_nothing(com_backend):
backend, document = com_backend
dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE")
with pytest.raises(ValueError, match="already exists"):
await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None))
with pytest.raises(ValueError, match="text_style"):
await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"}))
with pytest.raises(ValueError, match="missing"):
await backend.mleaderstyle_create("X", {"arrow_size": 2.5})
with pytest.raises(ValueError, match="arrow_size"):
await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0})
assert document.calls == [], "nothing reached ActiveX"
assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"]