feat(styles): dimension, text and multileader styles on both engines; $DIMSTYLE/$TEXTSTYLE reach new entities headlessly

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-17 09:39:16 +03:00
co-authored by Claude Opus 5
parent 2e2cd2773d
commit cc3bd4876b
6 changed files with 1633 additions and 7 deletions
+2
View File
@@ -33,6 +33,7 @@ from backends.contracts import (
PremiumContract,
SettingsContract,
SolidContract,
StylesContract,
SystemContract,
TransactionContract,
ViewContract,
@@ -184,6 +185,7 @@ class AutoCADBackend(
PremiumContract,
GdtContract,
SettingsContract,
StylesContract,
):
"""All backends implement this interface.
+368
View File
@@ -254,6 +254,115 @@ def _regen():
log.debug("Regen failed: %s", exc)
# ── styles (track E): module helpers ─────────────────────────────────────────
#
# The ActiveX object model has no per-style getters or setters for dimension
# variables: `AcadDimStyle` exposes `Name` and `CopyFrom` and nothing else. A
# style's values are therefore written the way AutoCAD's own DIMSTYLE command
# writes them — set the DIM* system variables on the document (which makes
# them overrides of the *current* style) and `CopyFrom(document)` to save
# those settings into the target style. Assigning `ActiveDimStyle` restores
# that style's saved settings and discards unsaved overrides (AutoCAD's
# `-DIMSTYLE Restore` rule; the fake in tests/test_styles.py models exactly
# this, and scripts/smoke_settings_com.py confirms it live). Reading is
# one-sided: `GetVariable` reads the *current* style only, so `dimstyle_list`
# reports values for the current style and `values_available: False` for the
# rest rather than cycling the active style behind the operator's back.
def _com_named(collection, name: str):
"""A collection member by name, case-insensitive (AutoCAD's rule), or None."""
wanted = name.strip().lower()
for index in range(collection.Count):
item = collection.Item(index)
if str(item.Name).lower() == wanted:
return item
return None
def _com_dimvar_for_write(var: str, value: Any) -> Any:
"""ActiveX takes DIMDSEP and the arrowhead/text-style names as strings."""
if var in ("DIMDSEP", "DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"):
return str(value)
if isinstance(value, float):
return float(value)
return int(value)
def _com_dimvar_for_report(var: str, raw: Any) -> Any:
if var == "DIMDSEP":
if isinstance(raw, int):
return "," if raw == 0 else chr(raw)
text = str(raw)
return text[:1] if text else ","
if var in ("DIMBLK", "DIMBLK1", "DIMBLK2"):
# AutoCAD reports the block name (``_OBLIQUE``, ``""`` for closed
# filled) — already canonical; the same read-side rule as ezdxf keeps
# the two engines' rows spelled identically.
from engineering.standards.dimstyles import reported_arrowhead
return reported_arrowhead(raw)
if var == "DIMTXSTY":
return str(raw)
return raw
def _same_dimvar(old: Any, new: Any) -> bool:
if isinstance(old, (int, float)) and isinstance(new, (int, float)):
return abs(float(old) - float(new)) < 1e-9
return str(old) == str(new)
def _com_dimensions_using(doc, style_name: str) -> list[str]:
"""Handles of every dimension, in every layout, whose StyleName is ``style_name``."""
wanted = style_name.lower()
handles: list[str] = []
for index in range(doc.Layouts.Count):
block = doc.Layouts.Item(index).Block
for position in range(block.Count):
obj = block.Item(position)
try:
if "Dimension" in str(obj.ObjectName) and str(obj.StyleName).lower() == wanted:
handles.append(str(obj.Handle))
except Exception:
continue
return handles
def _com_font_on_support_path(font_file: str) -> bool:
"""Whether AutoCAD's support path (Preferences.Files.SupportPath) holds the file."""
path = Path(font_file)
if path.is_absolute():
return path.is_file()
try:
support = str(_acad_app().Preferences.Files.SupportPath)
except Exception:
return False
return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder)
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
existing = _com_named(doc.TextStyles, name)
if existing is not None:
return str(existing.Name), False
preset = TEXT_PRESETS.get(name.upper())
if preset is None:
raise ValueError(
f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a "
f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create"
)
font_file, width, oblique = preset
style = doc.TextStyles.Add(name.upper())
style.fontFile = font_file
style.Width = float(width)
style.ObliqueAngle = deg2rad(oblique)
style.Height = 0.0
return name.upper(), True
def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_override=None):
"""Best-effort ISO 129 tolerance rendering on a live COM dimension.
@@ -814,6 +923,13 @@ 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"
),
),
},
)
@@ -4074,6 +4190,258 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
# ── styles (track E) ─────────────────────────────────────────────────────
#
# Fake-tested in tests/test_styles.py; executed live once by
# scripts/smoke_settings_com.py. See the module helper block for the
# ActiveX rules every method here relies on.
async def dimstyle_list(self) -> list[dict]:
from engineering.standards.dimstyles import PRESET_VARIABLES
def _sync():
doc = _acad_doc()
current = str(doc.ActiveDimStyle.Name)
rows = []
for index in range(doc.DimStyles.Count):
name = str(doc.DimStyles.Item(index).Name)
is_current = name.lower() == current.lower()
values = (
{
var: _com_dimvar_for_report(var, doc.GetVariable(var))
for var in PRESET_VARIABLES
}
if is_current
else None
)
rows.append(
{
"name": name,
"current": is_current,
"values": values,
"values_available": is_current,
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
from engineering.standards.dimstyles import PRESET_VARIABLES, validate_overrides
clean = sanitize_symbol_name(name, kind="dimstyle")
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_create: values is empty; resolve a preset first")
def _sync():
doc = _acad_doc()
if _com_named(doc.DimStyles, clean) is not None:
raise ValueError(
f"dimstyle_create: dimension style {clean!r} already exists; "
"use dimstyle_modify to change it"
)
# The text style must exist before DIMTXSTY can name it.
txsty, textstyle_created = _ensure_com_textstyle(
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
)
previous = doc.ActiveDimStyle
style = doc.DimStyles.Add(clean)
doc.ActiveDimStyle = style
for var, value in {**typed, "DIMTXSTY": txsty}.items():
doc.SetVariable(var, _com_dimvar_for_write(var, value))
style.CopyFrom(doc)
written = {
var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in PRESET_VARIABLES
}
if not set_current:
doc.ActiveDimStyle = previous
_regen()
return {
"ok": True,
"name": clean,
"values": written,
"written": sorted(typed),
"current": bool(set_current),
"textstyle_created": textstyle_created,
}
return await self._run(_sync)
async def dimstyle_modify(self, name: str, values: dict) -> dict:
from engineering.standards.dimstyles import validate_overrides
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable")
def _sync():
doc = _acad_doc()
style = _com_named(doc.DimStyles, name)
if style is None:
raise ValueError(
f"dimstyle_modify: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
if "DIMTXSTY" in typed and _com_named(doc.TextStyles, typed["DIMTXSTY"]) is None:
raise ValueError(
f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
"create it first with textstyle_create"
)
previous = doc.ActiveDimStyle
switched = str(previous.Name).lower() != str(style.Name).lower()
if switched:
doc.ActiveDimStyle = style
before = {var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in typed}
changed: dict[str, list] = {}
for var, value in typed.items():
if _same_dimvar(before[var], value):
continue
doc.SetVariable(var, _com_dimvar_for_write(var, value))
changed[var] = [before[var], value]
if changed:
style.CopyFrom(doc)
if switched:
doc.ActiveDimStyle = previous
using = _com_dimensions_using(doc, str(style.Name))
if changed:
_regen()
return {
"ok": True,
"name": str(style.Name),
"changed": changed,
"dimensions_using_style": using,
"rerender_required": False, # AutoCAD re-renders on regen
}
return await self._run(_sync)
async def dimstyle_set_current(self, name: str) -> dict:
def _sync():
doc = _acad_doc()
style = _com_named(doc.DimStyles, name)
if style is None:
raise ValueError(
f"dimstyle_set_current: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
previous = str(doc.ActiveDimStyle.Name)
changed = previous.lower() != str(style.Name).lower()
if changed:
doc.ActiveDimStyle = style
return {
"ok": True,
"current": str(style.Name),
"previous": previous,
"changed": changed,
}
return await self._run(_sync)
async def textstyle_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
current = str(doc.ActiveTextStyle.Name)
rows = []
for index in range(doc.TextStyles.Count):
style = doc.TextStyles.Item(index)
rows.append(
{
"name": str(style.Name),
"font": str(style.fontFile),
"height": float(style.Height),
"width_factor": float(style.Width),
"oblique_deg": rad2deg(float(style.ObliqueAngle)),
"current": str(style.Name).lower() == current.lower(),
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
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:
from engineering.standards.textstyles import validate_textstyle
spec = validate_textstyle(name, font, height, width_factor, oblique_deg)
def _sync():
doc = _acad_doc()
if _com_named(doc.TextStyles, spec["name"]) is not None:
raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
style = doc.TextStyles.Add(spec["name"])
style.fontFile = spec["font_file"]
style.Width = spec["width_factor"]
style.ObliqueAngle = deg2rad(spec["oblique_deg"])
style.Height = spec["height"]
if set_current:
doc.ActiveTextStyle = style
return {
"ok": True,
"name": spec["name"],
"font": spec["font_file"],
"font_resolved": spec["known"] or _com_font_on_support_path(spec["font_file"]),
"current": bool(set_current),
}
return await self._run(_sync)
async def textstyle_set_current(self, name: str) -> dict:
def _sync():
doc = _acad_doc()
style = _com_named(doc.TextStyles, name)
if style is None:
raise ValueError(
f"textstyle_set_current: text style {name!r} does not exist; "
"textstyle_list names the styles this drawing holds"
)
previous = str(doc.ActiveTextStyle.Name)
changed = previous.lower() != str(style.Name).lower()
if changed:
doc.ActiveTextStyle = style
return {
"ok": True,
"current": str(style.Name),
"previous": previous,
"changed": changed,
}
return await self._run(_sync)
async def mleaderstyle_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
try:
dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE")
except Exception:
return []
rows = []
for index in range(dictionary.Count):
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,
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod
+2
View File
@@ -24,6 +24,7 @@ from backends.contracts.linetypes import LinetypeContract
from backends.contracts.premium import PremiumContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
from backends.contracts.styles import StylesContract
from backends.contracts.system import SystemContract
from backends.contracts.transactions import TransactionContract
from backends.contracts.view import ViewContract
@@ -45,6 +46,7 @@ __all__ = [
"PremiumContract",
"SettingsContract",
"SolidContract",
"StylesContract",
"SystemContract",
"TransactionContract",
"ViewContract",
+143
View File
@@ -0,0 +1,143 @@
"""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
from backends.capability import capability
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}]``.
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``.
"""
...
# 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)"
),
)
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."""
...
+475 -7
View File
@@ -591,7 +591,7 @@ def _header_dimvar(doc, header_var: str, coerce):
def _style_dimvar(doc, attribute: str, fallback, coerce):
"""What the renderer would use with no header and no override in play."""
try:
style = doc.dimstyles.get(_DIMSTYLE_NAME)
style = doc.dimstyles.get(_current_dimstyle_name(doc))
if style.dxf.hasattr(attribute):
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
if stored is not None:
@@ -662,6 +662,203 @@ def _with_header_dimvars(doc, override: dict | None) -> dict | None:
return merged
# ── styles (track E): module helpers ─────────────────────────────────────────
#
# `$DIMSTYLE` names the style new dimensions are created with, exactly as on
# a live seat. Two measured ezdxf facts shape everything below:
#
# * `ezdxf.new()` writes `$DIMSTYLE "ISO-25"` into a fresh header with no such
# table entry (only `Standard` exists). `_current_dimstyle_name` therefore
# falls back to `Standard` when the header names nothing in the table, and
# `dimstyle_create` pins the header so creating a style *called* ISO-25 does
# not make it current by accident.
# * `_with_header_dimvars` folds every `$DIM*` that differs from the current
# style's stored value onto the next dimension. Making a style current
# without loading its values into the header leaves the previous style's
# header winning: measured, ISO-25 (comma) after `drawing_new` (point)
# rendered "33.33". `_sync_header_to_dimstyle` is what a live seat's
# `-DIMSTYLE Restore` does, and it makes the fold empty.
def _current_dimstyle_name(doc) -> str:
"""The dimension style new dimensions are created with.
`$DIMSTYLE` when the table holds it (in the table's spelling); otherwise
`Standard` — ezdxf's own substitution for a missing style, made explicit.
"""
try:
name = doc.header.get("$DIMSTYLE", _DIMSTYLE_NAME)
except Exception:
return _DIMSTYLE_NAME
if isinstance(name, str) and name and doc.dimstyles.has_entry(name):
return doc.dimstyles.get(name).dxf.name
return _DIMSTYLE_NAME
def _current_textstyle_name(doc) -> str:
"""The text style new TEXT/MTEXT carry: `$TEXTSTYLE`, else `Standard`."""
try:
name = doc.header.get("$TEXTSTYLE", "Standard")
except Exception:
return "Standard"
if isinstance(name, str) and name and doc.styles.has_entry(name):
return doc.styles.get(name).dxf.name
return "Standard"
def _dimstyle_schema_default(var: str):
"""What AutoCAD reads for a DIMSTYLE entry that omits the code."""
from ezdxf.entities.dimstyle import acdb_dimstyle
attrib = acdb_dimstyle.attribs.get(var.lower())
return None if attrib is None else attrib.default
_ARROWHEAD_VARS = ("DIMBLK", "DIMBLK1", "DIMBLK2")
def _dimstyle_value(style, var: str):
"""A style's value for ``var`` in the tool vocabulary (DIMDSEP as the
character, arrowheads in the canonical ``_OBLIQUE`` spelling)."""
from engineering.standards.dimstyles import reported_arrowhead
attr = var.lower()
raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
if raw is None:
raw = _dimstyle_schema_default(var)
if var == "DIMDSEP":
code = 44 if raw is None else int(raw)
return "," if code == 0 else chr(code)
if var == "DIMTXSTY":
return str(raw) if raw else "Standard"
if var in _ARROWHEAD_VARS:
# Measured: ezdxf holds the writer's ``OBLIQUE`` in-session and the
# block name ``_OBLIQUE`` after a reload. Raw, one style would be
# spelled two ways and re-setting a value to itself would report a
# change (spec §8.3); canonicalised, both reads agree.
return reported_arrowhead(raw)
return raw
def _read_dimstyle_values(style, variables=None) -> dict[str, Any]:
from engineering.standards.dimstyles import PRESET_VARIABLES
return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)}
def _require_arrowhead_blocks(doc, values: dict[str, Any]) -> None:
"""Refuse a user arrowhead block the drawing does not define — before any write.
A built-in (``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block:
ezdxf creates the built-in's block on first use. A user block name passes
the data layer (it cannot see the drawing) and, stored, ezdxf raises
``DXFUndefinedBlockError`` at the next dimension render and ``DXFValueError:
Block "MYARROW" does not exist`` at ``doc.write`` — after the create tool
already said ``ok``. ``doc.blocks`` membership is case-insensitive, and a
reload reports the block table's own spelling (measured: ``myarrow`` stored,
``MYARROW`` after reopen), so the name is rewritten in ``values`` to that
spelling here — the same "reported in the table's own spelling" rule the
style names follow, and what keeps a re-set after reopen a non-change.
"""
from engineering.standards.dimstyles import ARROWHEAD_BLOCKS
for var in _ARROWHEAD_VARS:
name = values.get(var)
if not name or name in ARROWHEAD_BLOCKS:
continue
if name not in doc.blocks:
raise ValueError(
f"{var}: block {name!r} does not exist in this drawing; define it first "
"with block_define, or name one of AutoCAD's built-in arrowheads"
)
values[var] = doc.blocks.get(name).name
def _write_dimstyle_values(style, values: dict[str, Any]) -> None:
"""Store typed values (from ``validate_overrides``) on a DIMSTYLE entry.
DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a
*stored* 0.0 and rounds every dimension to a whole unit, so it is
discarded rather than stored (the `_normalise_dimstyle_rounding` lesson).
Arrowheads arrive in the canonical ``_OBLIQUE`` spelling (what ActiveX
takes verbatim); ezdxf's writer wants ``OBLIQUE`` and raises at
``doc.write`` on the underscore form, so they go through
``ezdxf_arrowhead``. The caller has run ``_require_arrowhead_blocks``.
"""
from engineering.standards.dimstyles import ezdxf_arrowhead
for var, value in values.items():
attr = var.lower()
if var == "DIMRND" and float(value) == 0.0:
style.dxf.discard("dimrnd")
continue
if var == "DIMDSEP":
value = ord(value)
elif var in _ARROWHEAD_VARS:
value = ezdxf_arrowhead(value)
style.dxf.set(attr, value)
def _sync_header_to_dimstyle(doc, style) -> None:
"""Load the style's stored variables into ``$DIM*`` — a live seat's Restore."""
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST
for var in sorted(DIM_VARIABLE_WHITELIST):
attr = var.lower()
if not style.dxf.hasattr(attr):
continue
raw = style.dxf.get(attr)
if raw is None:
continue
doc.header[f"${var}"] = raw
def _font_available(font_file: str) -> bool:
"""Whether ezdxf's font manager can find ``font_file`` on this machine."""
try:
from ezdxf.fonts import fonts
return bool(fonts.font_manager.has_font(font_file))
except Exception:
return False
def _ensure_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
"""``(table spelling, created)`` for a text style that must exist.
A bundled preset (ISOCP / ISOCPEUR / ARIAL / ROMANS) is created on demand;
any other missing name is refused by ``refusal_key`` before a write.
"""
from engineering.standards.textstyles import TEXT_PRESETS
if doc.styles.has_entry(name):
return doc.styles.get(name).dxf.name, False
preset = TEXT_PRESETS.get(name.upper())
if preset is None:
raise ValueError(
f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a "
f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create"
)
font_file, width, oblique = preset
doc.styles.new(
name.upper(),
dxfattribs={"font": font_file, "width": width, "oblique": oblique, "height": 0.0},
)
return name.upper(), True
def _dimensions_using(doc, style_name: str) -> list[str]:
"""Handles of every DIMENSION, in any layout, drawn with ``style_name``."""
wanted = style_name.lower()
handles: list[str] = []
for layout in doc.layouts:
for entity in layout.query("DIMENSION"):
if str(entity.dxf.get("dimstyle", "")).lower() == wanted:
handles.append(entity.dxf.handle)
return handles
def _round_measure(result: dict) -> dict:
"""Six decimal places, matching analysis_measure_distance."""
result["area"] = round(float(result["area"]), 6)
@@ -1058,6 +1255,7 @@ class EzdxfBackend(AutoCADBackend):
False, reason="acis_generation_requires_live_autocad"
),
"lisp": FeatureCapability(False, reason="live_com_only"),
"mleaderstyle": FeatureCapability(True, "native"),
},
)
@@ -2719,6 +2917,7 @@ class EzdxfBackend(AutoCADBackend):
ent = msp.add_text(
text,
dxfattribs={
"style": _current_textstyle_name(self._require_doc()),
"height": float(height),
"rotation": float(rotation),
"insert": (float(x), float(y)),
@@ -2745,7 +2944,11 @@ class EzdxfBackend(AutoCADBackend):
msp = self._msp()
ent = msp.add_mtext(
text,
dxfattribs={"char_height": float(height), "width": float(width)},
dxfattribs={
"style": _current_textstyle_name(self._require_doc()),
"char_height": float(height),
"width": float(width),
},
)
ent.dxf.insert = (float(x), float(y), 0.0)
if rotation:
@@ -3331,7 +3534,7 @@ class EzdxfBackend(AutoCADBackend):
p2=(float(x2), float(y2)),
angle=float(rotation),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
dimstyle=_current_dimstyle_name(self._require_doc()),
override=override or None,
)
dim.render()
@@ -3361,7 +3564,7 @@ class EzdxfBackend(AutoCADBackend):
distance=math.sqrt(
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
),
dimstyle=_DIMSTYLE_NAME,
dimstyle=_current_dimstyle_name(self._require_doc()),
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
@@ -3393,7 +3596,7 @@ class EzdxfBackend(AutoCADBackend):
line1=((vxf, vyf), (float(x1), float(y1))),
line2=((vxf, vyf), (float(x2), float(y2))),
location=(float(tx), float(ty)),
dimstyle=_DIMSTYLE_NAME,
dimstyle=_current_dimstyle_name(self._require_doc()),
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
@@ -3438,7 +3641,7 @@ class EzdxfBackend(AutoCADBackend):
cyf + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
dimstyle=_current_dimstyle_name(self._require_doc()),
override=override or None,
)
dim.render()
@@ -3488,7 +3691,7 @@ class EzdxfBackend(AutoCADBackend):
cy + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
dimstyle=_current_dimstyle_name(self._require_doc()),
override=override or None,
)
dim.render()
@@ -5548,6 +5751,271 @@ class EzdxfBackend(AutoCADBackend):
"error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)",
}
# ── styles (track E) ─────────────────────────────────────────────────────
async def dimstyle_list(self) -> list[dict]:
def _sync():
doc = self._require_doc()
current = _current_dimstyle_name(doc)
rows = [
{
"name": style.dxf.name,
"current": style.dxf.name == current,
"values": _read_dimstyle_values(style),
"values_available": True,
}
for style in doc.dimstyles
]
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._async(_sync)
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
from engineering.standards.dimstyles import validate_overrides
from security import sanitize_symbol_name
clean = sanitize_symbol_name(name, kind="dimstyle")
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_create: values is empty; resolve a preset first")
def _sync():
doc = self._require_doc()
if doc.dimstyles.has_entry(clean):
raise ValueError(
f"dimstyle_create: dimension style {clean!r} already exists; "
"use dimstyle_modify to change it"
)
previous = _current_dimstyle_name(doc)
_require_arrowhead_blocks(doc, typed)
txsty, textstyle_created = _ensure_textstyle(
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
)
style = doc.dimstyles.new(clean)
_write_dimstyle_values(style, {**typed, "DIMTXSTY": txsty})
if set_current:
doc.header["$DIMSTYLE"] = clean
_sync_header_to_dimstyle(doc, style)
else:
# Measured: ezdxf.new() writes $DIMSTYLE "ISO-25" into a fresh
# header with no such table entry, so creating a style of that
# name would silently make it current. Pin the header to the
# style that was effectively current before this call.
doc.header["$DIMSTYLE"] = previous
self._mark_dirty()
return {
"ok": True,
"name": clean,
"values": _read_dimstyle_values(style),
"written": sorted(typed),
"current": bool(set_current),
"textstyle_created": textstyle_created,
}
return await self._async(_sync)
async def dimstyle_modify(self, name: str, values: dict) -> dict:
from engineering.standards.dimstyles import validate_overrides
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable")
def _sync():
doc = self._require_doc()
if not doc.dimstyles.has_entry(name):
raise ValueError(
f"dimstyle_modify: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
style = doc.dimstyles.get(name)
if "DIMTXSTY" in typed and not doc.styles.has_entry(typed["DIMTXSTY"]):
raise ValueError(
f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
"create it first with textstyle_create"
)
_require_arrowhead_blocks(doc, typed)
before = _read_dimstyle_values(style, tuple(typed))
_write_dimstyle_values(style, typed)
after = _read_dimstyle_values(style, tuple(typed))
changed = {var: [before[var], after[var]] for var in typed if before[var] != after[var]}
if changed and style.dxf.name == _current_dimstyle_name(doc):
_sync_header_to_dimstyle(doc, style)
using = _dimensions_using(doc, style.dxf.name)
if changed:
self._mark_dirty()
return {
"ok": True,
"name": style.dxf.name,
"changed": changed,
"dimensions_using_style": using,
# A headless DIMENSION carries its rendered block; it shows the
# old style until redrawn. Stated, not silent.
"rerender_required": bool(changed and using),
}
return await self._async(_sync)
async def dimstyle_set_current(self, name: str) -> dict:
def _sync():
doc = self._require_doc()
if not doc.dimstyles.has_entry(name):
raise ValueError(
f"dimstyle_set_current: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
style = doc.dimstyles.get(name)
previous = _current_dimstyle_name(doc)
doc.header["$DIMSTYLE"] = style.dxf.name
_sync_header_to_dimstyle(doc, style)
changed = style.dxf.name != previous
if changed:
self._mark_dirty()
return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}
return await self._async(_sync)
async def textstyle_list(self) -> list[dict]:
def _sync():
doc = self._require_doc()
current = _current_textstyle_name(doc)
rows = [
{
"name": style.dxf.name,
"font": str(style.dxf.get("font", "") or ""),
"height": float(style.dxf.get("height", 0.0) or 0.0),
"width_factor": float(style.dxf.get("width", 1.0) or 1.0),
"oblique_deg": float(style.dxf.get("oblique", 0.0) or 0.0),
"current": style.dxf.name == current,
}
for style in doc.styles
]
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._async(_sync)
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:
from engineering.standards.textstyles import validate_textstyle
spec = validate_textstyle(name, font, height, width_factor, oblique_deg)
def _sync():
doc = self._require_doc()
if doc.styles.has_entry(spec["name"]):
raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
doc.styles.new(
spec["name"],
dxfattribs={
"font": spec["font_file"],
"width": spec["width_factor"],
"oblique": spec["oblique_deg"],
"height": spec["height"],
},
)
if set_current:
doc.header["$TEXTSTYLE"] = spec["name"]
self._mark_dirty()
return {
"ok": True,
"name": spec["name"],
"font": spec["font_file"],
# A DXF stores the name only; a font nobody can find is written
# and *said* to be unresolved, never refused.
"font_resolved": spec["known"] or _font_available(spec["font_file"]),
"current": bool(set_current),
}
return await self._async(_sync)
async def textstyle_set_current(self, name: str) -> dict:
def _sync():
doc = self._require_doc()
if not doc.styles.has_entry(name):
raise ValueError(
f"textstyle_set_current: text style {name!r} does not exist; "
"textstyle_list names the styles this drawing holds"
)
style = doc.styles.get(name)
previous = _current_textstyle_name(doc)
doc.header["$TEXTSTYLE"] = style.dxf.name
changed = style.dxf.name != previous
if changed:
self._mark_dirty()
return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}
return await self._async(_sync)
async def mleaderstyle_list(self) -> list[dict]:
def _sync():
doc = self._require_doc()
rows = []
for name, style in doc.mleader_styles:
handle = style.dxf.get("text_style_handle")
text_style = doc.entitydb.get(handle) if handle else None
rows.append(
{
"name": name,
# schema defaults (measured: 4 / 2.0 / 4) for an unset field
"arrow_size": float(style.dxf.get("arrow_head_size", 4.0)),
"landing_gap": float(style.dxf.get("landing_gap_size", 2.0)),
"text_style": text_style.dxf.name if text_style is not None else "Standard",
"text_height": float(style.dxf.get("char_height", 4.0)),
"values_available": True,
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._async(_sync)
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
from engineering.standards.mleaderstyles import MLEADER_KEYS
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"
)
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"
)
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.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"]),
},
"textstyle_created": textstyle_created,
}
return await self._async(_sync)
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod
+643
View File
@@ -0,0 +1,643 @@
"""Dimension, text and multileader styles on both engines.
Headless: real ezdxf tables, and the *rendered* dimension block as the witness
that a current style reaches the next dimension (the header alone was never
the thing that could lie — see tests/test_dimension_header_vars.py). Live:
a fake ActiveX surface that records the call shapes the COM block relies on
and models the one AutoCAD rule it depends on — making a style current
restores that style's saved DIM* settings.
"""
from __future__ import annotations
import types
import pytest
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name
from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle
from engineering.standards.mleaderstyles import resolve_mleaderstyle
pytestmark = pytest.mark.asyncio
def _rendered(backend, handle):
"""(text, text height, arrow xscales) out of the dimension's geometry block."""
dim = backend._doc.entitydb.get(handle)
block = backend._doc.blocks.get(dim.dxf.geometry)
text = height = None
arrows = []
for entity in block:
if entity.dxftype() == "MTEXT" and text is None:
text, height = entity.text, round(float(entity.dxf.char_height), 4)
elif entity.dxftype() == "TEXT" and text is None:
text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
elif entity.dxftype() == "INSERT":
arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
if text is None:
raise AssertionError("the dimension block carries no text")
return text, height, sorted(set(arrows))
# ── headless engine ─────────────────────────────────────────────────────────
async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend):
rows = await backend.dimstyle_list()
assert [r["name"] for r in rows] == ["Standard"]
assert rows[0]["current"] is True and rows[0]["values_available"] is True
assert tuple(rows[0]["values"]) == PRESET_VARIABLES
assert rows[0]["values"]["DIMTXT"] == 2.5
assert rows[0]["values"]["DIMDSEP"] == ".", "drawing_new's ISO point"
assert rows[0]["values"]["DIMTXSTY"] == "Standard", "unstored code reports the schema default"
async def test_dimstyle_create_iso25_writes_every_value_and_creates_isocp(backend):
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
assert result["ok"] is True and result["name"] == "ISO-25"
assert result["textstyle_created"] is True and result["current"] is False
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
assert result["written"] == sorted(PRESET_VARIABLES)
style = backend._doc.dimstyles.get("ISO-25")
assert style.dxf.dimtxt == 2.5 and style.dxf.dimdsep == 44 and style.dxf.dimtad == 1
assert style.dxf.dimtxsty == "ISOCP" and style.dxf.dimlwd == -2 and style.dxf.dimblk == ""
assert backend._doc.styles.get("ISOCP").dxf.font == "isocp.shx"
# ezdxf.new() writes $DIMSTYLE "ISO-25" with no such entry; creating the
# entry must not make it current by accident.
assert backend._doc.header["$DIMSTYLE"] == "Standard"
assert _current_dimstyle_name(backend._doc) == "Standard"
async def test_set_current_syncs_the_header_and_the_next_dimension_renders_with_it(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
header = backend._doc.header
assert header["$DIMSTYLE"] == "ISO-25"
assert header["$DIMDSEP"] == 44 and header["$DIMTXT"] == 2.5 and header["$DIMTXSTY"] == "ISOCP"
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert backend._doc.entitydb.get(dim.handle).dxf.dimstyle == "ISO-25"
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
dim2 = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
assert backend._doc.entitydb.get(dim2.handle).dxf.dimstyle == "ANSI"
assert _rendered(backend, dim2.handle) == ("33.33", 3.0, [3.0])
for creator, args in (
(backend.dimension_aligned, (0, 0, 10, 10, 5, 15)),
(backend.dimension_radius, (50, 50, 60, 50)),
(backend.dimension_diameter, (70, 0, 90, 0)),
(backend.dimension_angular, (0, 0, 10, 0, 0, 10, 5, 5)),
):
created = await creator(*args)
assert backend._doc.entitydb.get(created.handle).dxf.dimstyle == "ANSI", creator.__name__
async def test_dimstyle_modify_reports_only_what_moved_and_the_dimensions_using_it(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
dim = await backend.dimension_linear(0, 0, 10, 0, 5, -10)
result = await backend.dimstyle_modify("iso-25", {"DIMTXT": 2.5, "DIMDEC": 3, "dimrnd": 0})
assert result["name"] == "ISO-25"
assert result["changed"] == {"DIMDEC": [2, 3]}
assert result["dimensions_using_style"] == [dim.handle]
assert result["rerender_required"] is True
assert backend._doc.header["$DIMDEC"] == 3, "the current style's header twin follows"
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimrnd"), (
"DIMRND 0 is 'no rounding' and must be discarded, not stored (xround(x, 0.0) rounds)"
)
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
assert again["changed"] == {} and again["rerender_required"] is False
on_standard = await backend.dimstyle_modify("Standard", {"DIMDEC": 4})
assert on_standard["dimensions_using_style"] == [] and on_standard["rerender_required"] is False
assert backend._doc.header["$DIMDEC"] == 3, "a non-current style does not touch the header"
async def test_refusals_happen_before_anything_is_written(backend):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="already exists"):
await backend.dimstyle_create("iso-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMTXSTY"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
assert not backend._doc.dimstyles.has_entry("X")
with pytest.raises(ValueError, match="DIMFOO"):
await backend.dimstyle_create("Y", {"DIMFOO": 1})
assert not backend._doc.dimstyles.has_entry("Y")
with pytest.raises(ValueError, match="DIMDEC"):
await backend.dimstyle_modify("ISO-25", {"DIMDEC": 9})
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
with pytest.raises(ValueError, match="empty"):
await backend.dimstyle_modify("ISO-25", {})
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_set_current("NOPE")
assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2
async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path):
"""`validate_overrides` hands the canonical `_OBLIQUE`; stored verbatim ezdxf
raises at render and at `doc.write`. The witness is the rendered INSERT and
a save/reopen that reports the same name before and after."""
values = resolve_dimstyle(
"iso-25", {"DIMBLK": "oblique", "DIMSAH": 1, "DIMBLK1": "_DOT", "DIMBLK2": "."}
)
result = await backend.dimstyle_create("ISO-25", values, set_current=True)
assert result["values"]["DIMBLK"] == "_OBLIQUE"
style = backend._doc.dimstyles.get("ISO-25")
assert style.dxf.dimblk == "OBLIQUE" and style.dxf.dimblk1 == "DOT" and style.dxf.dimblk2 == ""
dim = await backend.dimension_linear(0, 0, 20, 0, 10, -10)
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
assert {e.dxf.name for e in block if e.dxftype() == "INSERT"} == {"_DOT", "_CLOSEDFILLED"}, (
"DIMSAH on: the two separate arrowheads win over DIMBLK"
)
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)"
path = str(tmp_path / "arrows.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
rows = {r["name"]: r for r in await backend.dimstyle_list()}
assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling"
reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"})
assert reopened["changed"] == {}
async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend):
with pytest.raises(ValueError, match="DIMBLK: block 'MYARROW' does not exist"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMBLK": "MYARROW"}))
assert not backend._doc.dimstyles.has_entry("X")
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMBLK1: block 'MYARROW' does not exist"):
await backend.dimstyle_modify("ISO-25", {"DIMSAH": 1, "DIMBLK1": "MYARROW"})
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimsah"), "nothing written"
backend._doc.blocks.new("MYARROW").add_line((0, 0), (1, 1))
result = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "myarrow"})
assert result["changed"] == {"DIMBLK": ["", "MYARROW"]}, "the block table's own spelling"
assert backend._doc.dimstyles.get("ISO-25").dxf.dimblk == "MYARROW"
async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path):
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
result = await backend.dimstyle_set_current("iso-25")
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
same = await backend.dimstyle_set_current("ISO-25")
assert same["changed"] is False and same["previous"] == "ISO-25"
path = str(tmp_path / "styles.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
rows = await backend.dimstyle_list()
assert [r["name"] for r in rows if r["current"]] == ["ISO-25"]
assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == ","
async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend):
result = await backend.textstyle_create("ISOCP", "isocp", set_current=True)
assert result == {
"ok": True,
"name": "ISOCP",
"font": "isocp.shx",
"font_resolved": True,
"current": True,
}
rows = await backend.textstyle_list()
assert [r["name"] for r in rows] == ["ISOCP", "Standard"]
assert rows[0] == {
"name": "ISOCP",
"font": "isocp.shx",
"height": 0.0,
"width_factor": 1.0,
"oblique_deg": 0.0,
"current": True,
}
assert backend._doc.header["$TEXTSTYLE"] == "ISOCP"
assert _current_textstyle_name(backend._doc) == "ISOCP"
text = await backend.entity_create_text("note", 0, 0)
mtext = await backend.entity_create_mtext("note", 0, 10)
assert backend._doc.entitydb.get(text.handle).dxf.style == "ISOCP"
assert backend._doc.entitydb.get(mtext.handle).dxf.style == "ISOCP"
back = await backend.textstyle_set_current("standard")
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
assert (
backend._doc.entitydb.get((await backend.entity_create_text("x", 0, 0)).handle).dxf.style
== "Standard"
)
async def test_textstyle_unknown_font_is_written_but_reported_unresolved(backend):
result = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15)
assert result["font_resolved"] is False and result["current"] is False
style = backend._doc.styles.get("ODD")
assert style.dxf.font == "acadmcp-missing-font.ttf"
assert style.dxf.height == 3.5 and style.dxf.width == 0.8 and style.dxf.oblique == 15.0
async def test_textstyle_refusals_write_nothing(backend):
await backend.textstyle_create("ISOCP", "isocp.shx")
with pytest.raises(ValueError, match="already exists"):
await backend.textstyle_create("isocp", "isocp.shx")
with pytest.raises(ValueError, match="width_factor"):
await backend.textstyle_create("Z", "arial.ttf", width_factor=0)
with pytest.raises(ValueError, match="oblique_deg"):
await backend.textstyle_create("Z", "arial.ttf", oblique_deg=90)
with pytest.raises(ValueError, match="height"):
await backend.textstyle_create("Z", "arial.ttf", height=-1)
with pytest.raises(ValueError, match="font"):
await backend.textstyle_create("Z", "")
assert not backend._doc.styles.has_entry("Z")
with pytest.raises(ValueError, match="does not exist"):
await backend.textstyle_set_current("NOPE")
async def test_mleaderstyle_create_iso_and_list(backend):
result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
assert result["ok"] is True and result["textstyle_created"] is True
assert result["values"] == {
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5,
}
style = backend._doc.mleader_styles.get("ISO")
assert style.dxf.arrow_head_size == 2.5 and style.dxf.landing_gap_size == 1.0
assert backend._doc.entitydb[style.dxf.text_style_handle].dxf.name == "ISOCP"
rows = await backend.mleaderstyle_list()
assert rows == [
{
"name": "ISO",
"arrow_size": 2.5,
"landing_gap": 1.0,
"text_style": "ISOCP",
"text_height": 2.5,
"values_available": True,
},
{
"name": "Standard",
"arrow_size": 4.0,
"landing_gap": 2.0,
"text_style": "Standard",
"text_height": 4.0,
"values_available": True,
},
]
async def test_mleaderstyle_refusals_write_nothing(backend):
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
with pytest.raises(ValueError, match="already exists"):
await backend.mleaderstyle_create("iso", 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})
assert not backend._doc.mleader_styles.has_entry("X")
async def test_both_capability_maps_declare_mleaderstyle(backend):
from backends.com_backend import ComBackend
assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True
com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"]
assert com["supported"] is False and "leader_create_mleader" in com["reason"]
# ── live engine, against a fake ActiveX surface ──────────────────────────────
class _FakeStyle:
def __init__(self, name, calls):
self.Name = name
self._calls = calls
self.fontFile = "txt.shx"
self.Width = 1.0
self.ObliqueAngle = 0.0
self.Height = 0.0
self.saved: dict | None = None # the DIM* values this style holds, once saved
def CopyFrom(self, source):
# AcadDimStyle.CopyFrom(document) saves the document's current
# dimension settings into this style.
self._calls.append(("CopyFrom", self.Name, source))
self.saved = dict(source.variables)
class _FakeCollection:
def __init__(self, kind, calls, *names):
self._kind = kind
self._calls = calls
self.entries = [_FakeStyle(name, calls) for name in names]
@property
def Count(self):
return len(self.entries)
def Item(self, index):
return self.entries[index]
def Add(self, name):
self._calls.append((f"{self._kind}.Add", name))
style = _FakeStyle(name, self._calls)
self.entries.append(style)
return style
class _FakeBlock:
def __init__(self, *entities):
self.entities = list(entities)
@property
def Count(self):
return len(self.entities)
def Item(self, index):
return self.entities[index]
class _FakeLayouts:
def __init__(self, *blocks):
self.layouts = [types.SimpleNamespace(Block=block) for block in blocks]
@property
def Count(self):
return len(self.layouts)
def Item(self, index):
return self.layouts[index]
class _FakeDictionary:
def __init__(self, *names):
self.objects = [types.SimpleNamespace(mleader_name=name) for name in names]
@property
def Count(self):
return len(self.objects)
def Item(self, index):
return self.objects[index]
def GetName(self, obj):
return obj.mleader_name
class _FakeDictionaries:
def __init__(self, entries):
self.entries = entries
def Item(self, name):
if name not in self.entries:
raise RuntimeError(f"no dictionary {name}")
return self.entries[name]
CURRENT_DIMVARS = {
"DIMTXT": 2.5,
"DIMASZ": 2.5,
"DIMEXE": 1.25,
"DIMEXO": 0.625,
"DIMGAP": 0.625,
"DIMTAD": 1,
"DIMTIH": 0,
"DIMTOH": 0,
"DIMDEC": 2,
"DIMDSEP": ".",
"DIMLUNIT": 2,
"DIMZIN": 8,
"DIMBLK": "",
"DIMTXSTY": "Standard",
"DIMLWD": -2,
"DIMLWE": -2,
"DIMSCALE": 1.0,
}
class _FakeDocument:
def __init__(self):
self.calls: list[tuple] = []
self.DimStyles = _FakeCollection("DimStyles", self.calls, "Standard")
self.TextStyles = _FakeCollection("TextStyles", self.calls, "Standard")
self.variables = dict(CURRENT_DIMVARS)
self._active_dim = self.DimStyles.entries[0]
self._active_dim.saved = dict(CURRENT_DIMVARS)
self._active_text = self.TextStyles.entries[0]
dim = types.SimpleNamespace(
ObjectName="AcDbRotatedDimension", StyleName="ISO-25", Handle="2A"
)
line = types.SimpleNamespace(ObjectName="AcDbLine", Handle="2B")
sheet_dim = types.SimpleNamespace(
ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C"
)
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
self.Dictionaries = _FakeDictionaries(
{"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")}
)
def GetVariable(self, name):
return self.variables[name]
def SetVariable(self, name, value):
self.calls.append(("SetVariable", name, value))
self.variables[name] = value
@property
def ActiveDimStyle(self):
return self._active_dim
@ActiveDimStyle.setter
def ActiveDimStyle(self, style):
# Making a style current restores its saved settings as the current
# DIM* variables and discards unsaved overrides (AutoCAD's -DIMSTYLE
# Restore rule; scripts/smoke_settings_com.py confirms it live). A
# style never saved (fresh DimStyles.Add) keeps the current variables.
self.calls.append(("ActiveDimStyle", style.Name))
self._active_dim = style
if style.saved is not None:
self.variables.update(style.saved)
@property
def ActiveTextStyle(self):
return self._active_text
@ActiveTextStyle.setter
def ActiveTextStyle(self, style):
self.calls.append(("ActiveTextStyle", style.Name))
self._active_text = style
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
document = _FakeDocument()
app = types.SimpleNamespace(
Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=""))
)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_regen", lambda: None)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, document
async def test_com_create_sets_variables_then_copies_from_the_document(com_backend):
backend, document = com_backend
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
assert result["ok"] is True and result["current"] is False
assert result["textstyle_created"] is True
names = [call[0] for call in document.calls]
assert names[:2] == ["TextStyles.Add", "DimStyles.Add"], (
"the text style exists before DIMTXSTY names it"
)
assert document.calls[2] == ("ActiveDimStyle", "ISO-25")
sets = [call for call in document.calls if call[0] == "SetVariable"]
assert len(sets) == 17
assert ("SetVariable", "DIMDSEP", ",") in sets, "DIMDSEP is a string on ActiveX"
assert ("SetVariable", "DIMTXSTY", "ISOCP") in sets
assert ("SetVariable", "DIMTXT", 2.5) in sets and ("SetVariable", "DIMTAD", 1) in sets
copy_index = names.index("CopyFrom")
assert copy_index > names.index("SetVariable")
assert document.calls[copy_index] == ("CopyFrom", "ISO-25", document)
assert document.calls[-1] == ("ActiveDimStyle", "Standard"), "restored: set_current was False"
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
isocp = document.TextStyles.entries[-1]
assert isocp.Name == "ISOCP" and isocp.fontFile == "isocp.shx"
async def test_com_create_set_current_keeps_the_new_style_active(com_backend):
backend, document = com_backend
result = await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
assert result["current"] is True
activations = [c for c in document.calls if c[0] == "ActiveDimStyle"]
assert activations == [("ActiveDimStyle", "ANSI")]
assert document.ActiveDimStyle.Name == "ANSI"
async def test_com_create_refuses_before_touching_activex(com_backend):
backend, document = com_backend
with pytest.raises(ValueError, match="already exists"):
await backend.dimstyle_create("standard", resolve_dimstyle("iso-25", None))
with pytest.raises(ValueError, match="DIMTXSTY"):
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
with pytest.raises(ValueError, match="DIMDEC"):
await backend.dimstyle_create("X", {"DIMDEC": 9})
assert document.calls == []
async def test_com_list_reports_values_for_the_current_style_only(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
rows = await backend.dimstyle_list()
assert [(r["name"], r["current"], r["values_available"]) for r in rows] == [
("ISO-25", False, False),
("Standard", True, True),
]
assert rows[0]["values"] is None
assert rows[1]["values"]["DIMDSEP"] == "." and tuple(rows[1]["values"]) == PRESET_VARIABLES
async def test_com_modify_switches_saves_and_switches_back(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
document.calls.clear()
result = await backend.dimstyle_modify("iso-25", {"DIMDEC": 3, "DIMTXT": 2.5})
assert result["name"] == "ISO-25"
assert result["changed"] == {"DIMDEC": [2, 3]}
assert result["dimensions_using_style"] == ["2A"], "model and paper space, dimensions only"
assert result["rerender_required"] is False, "AutoCAD re-renders on regen"
assert document.calls == [
("ActiveDimStyle", "ISO-25"),
("SetVariable", "DIMDEC", 3),
("CopyFrom", "ISO-25", document),
("ActiveDimStyle", "Standard"),
]
document.calls.clear()
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
assert again["changed"] == {}
assert [c[0] for c in document.calls] == ["ActiveDimStyle", "ActiveDimStyle"], (
"no CopyFrom when nothing moved"
)
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
async def test_com_set_current(com_backend):
backend, document = com_backend
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
result = await backend.dimstyle_set_current("ISO-25")
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
assert document.ActiveDimStyle.Name == "ISO-25"
with pytest.raises(ValueError, match="does not exist"):
await backend.dimstyle_set_current("NOPE")
async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
backend, document = com_backend
result = await backend.textstyle_create("ISOCP", "isocp", 0.0, 1.0, 0.0, set_current=True)
assert result == {
"ok": True,
"name": "ISOCP",
"font": "isocp.shx",
"font_resolved": True,
"current": True,
}
style = document.TextStyles.entries[-1]
assert (style.fontFile, style.Width, style.ObliqueAngle, style.Height) == (
"isocp.shx",
1.0,
0.0,
0.0,
)
assert document.ActiveTextStyle.Name == "ISOCP"
odd = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15.0)
assert odd["font_resolved"] is False
assert document.TextStyles.entries[-1].ObliqueAngle == pytest.approx(0.2617993878), "radians"
rows = await backend.textstyle_list()
assert [(r["name"], r["current"]) for r in rows] == [
("ISOCP", True),
("ODD", False),
("Standard", False),
]
assert rows[1]["oblique_deg"] == pytest.approx(15.0)
with pytest.raises(ValueError, match="already exists"):
await backend.textstyle_create("isocp", "isocp.shx")
back = await backend.textstyle_set_current("standard")
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend):
backend, document = com_backend
rows = await backend.mleaderstyle_list()
assert rows == [
{
"name": "Annotative",
"arrow_size": None,
"landing_gap": None,
"text_style": None,
"text_height": None,
"values_available": False,
},
{
"name": "Standard",
"arrow_size": None,
"landing_gap": None,
"text_style": None,
"text_height": None,
"values_available": False,
},
]
async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend):
from backends.com_backend import ComBackend
backend, document = com_backend
assert is_capability_default(ComBackend, "mleaderstyle_create")
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
assert excinfo.value.capability == "mleaderstyle"
assert "leader_create_mleader" in str(excinfo.value)
assert document.calls == []