diff --git a/backends/base.py b/backends/base.py index 0adbddc..65be4ce 100644 --- a/backends/base.py +++ b/backends/base.py @@ -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. diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..3316d4c 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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 diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index fb04be8..36d2f56 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -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", diff --git a/backends/contracts/styles.py b/backends/contracts/styles.py new file mode 100644 index 0000000..f0bdbf7 --- /dev/null +++ b/backends/contracts/styles.py @@ -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.""" + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..f9d244f 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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 diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..3aa989c --- /dev/null +++ b/tests/test_styles.py @@ -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 == []