Files
Umut CANandClaude Opus 5 e204f227b4 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
2026-09-21 16:48:20 +03:00

137 lines
6.2 KiB
Python

"""Dimension, text and multileader styles (track E, group S).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
Names are matched case-insensitively — AutoCAD's rule for every symbol
table — and reported in the table's own spelling. Every refusal is raised
before a table is touched: ``ValueError`` naming the key (a missing style, a
clash, a variable outside ``engineering.standards.dimstyles.DIM_VARIABLE_RANGES``),
``TypeError`` for a wrong type.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class StylesContract(ABC):
@abstractmethod
async def dimstyle_list(self) -> list[dict]:
"""Every DIMSTYLE entry: ``[{name, current, values, values_available}]``.
``values`` holds the seventeen preset variables
(``engineering.standards.dimstyles.PRESET_VARIABLES``) with ``DIMDSEP``
as the character. Headless every row carries them (the DXF's stored
value, else the schema default AutoCAD applies to an omitted code).
On the live engine ActiveX has no per-style getters — ``AcadDimStyle``
exposes ``Name`` and ``CopyFrom`` only — so ``values`` is read for the
*current* style and the other rows carry ``values: None`` with
``values_available: False`` rather than cycling the active style
behind the operator's back.
"""
...
@abstractmethod
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
"""Create a dimension style from a fully resolved ``values`` dict
(``engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)``).
Returns ``{ok, name, values, written, current, textstyle_created}``.
``DIMTXSTY`` must name a text style that exists or a bundled preset
(ISOCP / ISOCPEUR / ARIAL / ROMANS), which is created first and
reported in ``textstyle_created``; anything else is refused by name.
A name clash is refused (``dimstyle_modify`` changes an existing
style). With ``set_current`` the style becomes ``$DIMSTYLE`` *and* its
values become the current ``DIM*`` settings — what a live seat does —
so the next dimension carries them on both engines.
"""
...
@abstractmethod
async def dimstyle_modify(self, name: str, values: dict) -> dict:
"""Change variables on an existing style.
Returns ``{ok, name, changed: {VAR: [old, new]}, dimensions_using_style,
rerender_required}``. ``changed`` names only the variables that moved
(the ``hatch_edit`` rule: re-setting a value to itself is not a
change). Dimensions already drawn with the style are listed by handle;
``rerender_required`` is ``True`` headlessly when any exist and
something changed, because a headless DIMENSION carries its rendered
block and shows the old style until it is redrawn — AutoCAD re-renders
on the next regen, so the live engine reports ``False``. Modifying the
current style also updates the ``DIM*`` current settings.
"""
...
@abstractmethod
async def dimstyle_set_current(self, name: str) -> dict:
"""Make ``name`` the current dimension style.
Returns ``{ok, current, previous, changed}``. Sets ``$DIMSTYLE`` and
loads the style's stored variables into the current ``DIM*`` settings
(AutoCAD's ``-DIMSTYLE Restore``; on the live engine unsaved overrides
of the previous style are discarded, which is AutoCAD's own rule).
The dimension tools then create dimensions with it; the per-dimension
override path (tolerances, fits, text override) is unchanged.
"""
...
@abstractmethod
async def textstyle_list(self) -> list[dict]:
"""``[{name, font, height, width_factor, oblique_deg, current}]``."""
...
@abstractmethod
async def textstyle_create(
self,
name: str,
font: str,
height: float = 0.0,
width_factor: float = 1.0,
oblique_deg: float = 0.0,
set_current: bool = False,
) -> dict:
"""Create a text style. Returns ``{ok, name, font, font_resolved, current}``.
``font`` is a preset (``ISOCP`` / ``isocp.shx`` / ``isocp``, any case)
or any file name. A file that is neither a preset nor found on the
engine's font path is *written* and reported ``font_resolved: False``
— a DXF stores only the name and AutoCAD substitutes at open — never
refused. Refuses a clash, ``width_factor <= 0``, ``height < 0``, an
oblique angle outside ±85°, an empty font.
"""
...
@abstractmethod
async def textstyle_set_current(self, name: str) -> dict:
"""``$TEXTSTYLE``. Returns ``{ok, current, previous, changed}``; new
TEXT/MTEXT then carry the style on both engines."""
...
@abstractmethod
async def mleaderstyle_list(self) -> list[dict]:
"""``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.
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.
"""
...
@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``).
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."""
...