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
137 lines
6.2 KiB
Python
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."""
|
|
...
|