From 54fac471a98a4f80c5b3c5c02a92ada203ab196c Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:11:26 +0300 Subject: [PATCH 01/13] feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/standards/__init__.py | 39 +++ engineering/standards/dimstyles.py | 285 +++++++++++++++++++++ engineering/standards/mleaderstyles.py | 66 +++++ engineering/standards/textstyles.py | 100 ++++++++ tests/test_standards_presets.py | 326 +++++++++++++++++++++++++ 5 files changed, 816 insertions(+) create mode 100644 engineering/standards/__init__.py create mode 100644 engineering/standards/dimstyles.py create mode 100644 engineering/standards/mleaderstyles.py create mode 100644 engineering/standards/textstyles.py create mode 100644 tests/test_standards_presets.py diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py new file mode 100644 index 0000000..cac391e --- /dev/null +++ b/engineering/standards/__init__.py @@ -0,0 +1,39 @@ +"""Authored drafting standards data. + +Dimension, text and leader style presets live here (track E, group S); the +system-variable catalogue, paper sizes, plot styles and templates are sibling +modules added by their own groups and imported by path. Everything in this +package is data plus validation — no I/O, no ezdxf, no COM — so the backends, +the tools and the provenance tests read one copy. +""" + +from __future__ import annotations + +from engineering.standards.dimstyles import ( + DIM_VARIABLE_RANGES, + DIM_VARIABLE_WHITELIST, + PRESET_VARIABLES, + PRESETS, + check_dim_value, + describe_preset, + resolve_dimstyle, + validate_overrides, +) +from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle +from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle + +__all__ = [ + "DIM_VARIABLE_RANGES", + "DIM_VARIABLE_WHITELIST", + "MLEADER_PRESETS", + "PRESETS", + "PRESET_VARIABLES", + "TEXT_PRESETS", + "check_dim_value", + "describe_preset", + "resolve_dimstyle", + "resolve_font", + "resolve_mleaderstyle", + "validate_overrides", + "validate_textstyle", +] diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py new file mode 100644 index 0000000..8e81e42 --- /dev/null +++ b/engineering/standards/dimstyles.py @@ -0,0 +1,285 @@ +"""ISO-25 and ANSI dimension style presets as data. + +Every value in ``PRESETS`` is pinned by name in +``tests/test_standards_presets.py`` against the table in §4.1 of +``docs/superpowers/specs/2026-09-16-v1.6-settings-design.md``: + +* ``iso-25`` — ISO 129-1:2018 dimensioning at the 2.5 mm lettering row of + ISO 3098-1: 2.5 mm text and arrowheads, text above and aligned with the + dimension line, decimal comma (ISO 80000-1 allows either marker; AutoCAD's + own ISO-25 uses the comma). +* ``ansi`` — ASME Y14.2-2014 line conventions and lettering (3 mm / .12 in + lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads, + text centred in a gap and horizontal, decimal point. + +``DIMDSEP`` is a one-character *string* here. DXF stores the character code and +ezdxf raises on a string in the header, so the translation belongs to the +backends, not to the data. + +Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the +tools and the provenance tests all read one copy. +""" + +from __future__ import annotations + +import math +from typing import Any + +__all__ = [ + "ANSI", + "DIM_VARIABLE_RANGES", + "DIM_VARIABLE_WHITELIST", + "ISO_25", + "LINEWEIGHT_CODES", + "PRESETS", + "PRESET_SOURCES", + "PRESET_VARIABLES", + "check_dim_value", + "describe_preset", + "resolve_dimstyle", + "validate_overrides", +] + +#: The seventeen variables every preset sets, in the order the spec table +#: lists them. ``dimstyle_list`` reports exactly these. +PRESET_VARIABLES: tuple[str, ...] = ( + "DIMTXT", + "DIMASZ", + "DIMEXE", + "DIMEXO", + "DIMGAP", + "DIMTAD", + "DIMTIH", + "DIMTOH", + "DIMDEC", + "DIMDSEP", + "DIMLUNIT", + "DIMZIN", + "DIMBLK", + "DIMTXSTY", + "DIMLWD", + "DIMLWE", + "DIMSCALE", +) + +ISO_25: dict[str, Any] = { + "DIMTXT": 2.5, # text height, mm — ISO 3098-1 lettering row 2.5 + "DIMASZ": 2.5, # arrowhead length, matched to the text + "DIMEXE": 1.25, # extension line beyond the dimension line + "DIMEXO": 0.625, # extension line offset from the feature + "DIMGAP": 0.625, # gap between dimension line and text + "DIMTAD": 1, # text above the dimension line + "DIMTIH": 0, # inside text aligned with the dimension line + "DIMTOH": 0, # outside text aligned with the dimension line + "DIMDEC": 2, # linear decimals + "DIMDSEP": ",", # decimal comma + "DIMLUNIT": 2, # decimal linear units + "DIMZIN": 8, # trailing zeros suppressed + "DIMBLK": "", # closed filled arrowhead + "DIMTXSTY": "ISOCP", # ISO 3098 type B lettering (created if missing) + "DIMLWD": -2, # dimension line lineweight ByBlock + "DIMLWE": -2, # extension line lineweight ByBlock + "DIMSCALE": 1.0, +} + +ANSI: dict[str, Any] = { + "DIMTXT": 3.0, # 3 mm / .12 in lettering, ASME Y14.2-2014 + "DIMASZ": 3.0, # .125 in arrowheads, metric + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, # text centred in a gap in the dimension line + "DIMTIH": 1, # inside text horizontal + "DIMTOH": 1, # outside text horizontal + "DIMDEC": 2, + "DIMDSEP": ".", # decimal point + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMBLK": "", + "DIMTXSTY": "ROMANS", # AutoCAD's simplex roman, the ASME Gothic look + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + +PRESETS: dict[str, dict[str, Any]] = {"iso-25": ISO_25, "ansi": ANSI} + +PRESET_SOURCES: dict[str, str] = { + "iso-25": ( + "ISO 129-1:2018 (dimensioning) with the 2.5 mm lettering row of " + "ISO 3098-1 (text and arrow sizes); decimal comma per ISO 80000-1" + ), + "ansi": ( + "ASME Y14.2-2014 line conventions and lettering: 3 mm / .12 in lettering, " + ".125 in arrowheads, metric values" + ), +} + +#: The lineweights AutoCAD accepts, in hundredths of a millimetre, plus the +#: ByLayer / ByBlock / Default sentinels. Every ISO 128 weight is in here. +_LINEWEIGHT_TEXT = ( + "-3 -2 -1 0 5 9 13 15 18 20 25 30 35 40 50 53 60 70 80 90 100 106 120 140 158 200 211" +) +LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split()) + +#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each +#: value must sit in. The shape of each entry: +#: +#: * ``("float", low, high)`` — a number (bool refused) within the inclusive +#: bounds; a ``low`` of ``1e-9`` is the repo's spelling of "strictly positive" +#: (``backends/contracts/settings.py::_SETTING_RANGES``). +#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted). +#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused. +#: * ``("char",)`` — exactly one character. +#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow). +#: * ``("name",)`` — a non-empty DXF symbol-table name. +#: * a ``set`` — one of its members. +DIM_VARIABLE_RANGES: dict[str, tuple | set] = { + # the seventeen the presets set + "DIMTXT": ("float", 1e-9, 1e6), + "DIMASZ": ("float", 1e-9, 1e6), + "DIMEXE": ("float", 0.0, 1e6), + "DIMEXO": ("float", 0.0, 1e6), + "DIMGAP": ("float", -1e6, 1e6), # negative draws the basic-dimension box + "DIMTAD": ("int", 0, 4), + "DIMTIH": {0, 1}, + "DIMTOH": {0, 1}, + "DIMDEC": ("int", 0, 8), + "DIMDSEP": ("char",), + "DIMLUNIT": ("int", 1, 6), + "DIMZIN": ("int", 0, 15), # a bitmask + "DIMBLK": ("str",), + "DIMTXSTY": ("name",), + "DIMLWD": LINEWEIGHT_CODES, + "DIMLWE": LINEWEIGHT_CODES, + "DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport" + # tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist + "DIMTOL": {0, 1}, + "DIMTP": ("float", 0.0, 1e6), + "DIMTM": ("float", 0.0, 1e6), + "DIMTOLJ": ("int", 0, 2), + "DIMTFAC": ("float", 1e-9, 1e6), + "DIMLFAC": ("nonzero_float", -1e6, 1e6), + "DIMRND": ("float", 0.0, 1e6), # 0 = no rounding; the backends discard it, never store it + "DIMATFIT": ("int", 0, 3), + "DIMTMOVE": ("int", 0, 2), + "DIMCLRD": ("int", 0, 256), + "DIMCLRE": ("int", 0, 256), + "DIMCLRT": ("int", 0, 256), + "DIMSAH": {0, 1}, + "DIMBLK1": ("str",), + "DIMBLK2": ("str",), + "DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none +} + +DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES) + +#: DXF forbids these in a symbol-table name; the same rule ``security.py`` +#: applies to block and layer names, restated here so this module stays free +#: of fastmcp. +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def _number(key: str, value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {value!r} is not a finite number") + return number + + +def check_dim_value(name: str, value: Any) -> Any: + """``value`` coerced to what ``name`` stores, or ``TypeError``/``ValueError`` naming it. + + Nothing here writes; the backends call this before touching a table. + """ + key = str(name).strip().upper() + spec = DIM_VARIABLE_RANGES.get(key) + if spec is None: + raise ValueError( + f"{name}: not a dimension variable this tool writes; allowed: " + f"{sorted(DIM_VARIABLE_WHITELIST)}" + ) + if isinstance(spec, (set, frozenset)): + number = _number(key, value) + if number != int(number) or int(number) not in spec: + raise ValueError(f"{key}: {value!r} is not one of {sorted(spec)}") + return int(number) + kind = spec[0] + if kind in ("float", "nonzero_float"): + number = _number(key, value) + low, high = spec[1], spec[2] + if not low <= number <= high: + low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}" + raise ValueError( + f"{key}: {number:g} is out of range - must be {low_text} and at most {high:g}." + ) + if kind == "nonzero_float" and number == 0.0: + raise ValueError(f"{key}: 0 is refused (AutoCAD treats it as no factor at all)") + return number + if kind == "int": + number = _number(key, value) + if number != int(number): + raise ValueError(f"{key}: {value!r} must be a whole number") + low, high = spec[1], spec[2] + if not low <= number <= high: + raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}") + return int(number) + if not isinstance(value, str): + raise TypeError(f"{key}: expected a string, got {type(value).__name__}") + if kind == "char": + if len(value) != 1: + raise ValueError(f"{key}: {value!r} must be exactly one character") + return value + if kind == "name": + text = value.strip() + if not text: + raise ValueError(f"{key}: a style name cannot be empty") + bad = sorted(set(text) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}") + return text + return value # "str" + + +def validate_overrides(overrides: dict | None) -> dict[str, Any]: + """``overrides`` as a typed, upper-cased dict — every key checked, nothing written.""" + if overrides is None: + return {} + if not isinstance(overrides, dict): + raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}") + typed: dict[str, Any] = {} + for raw_key, value in overrides.items(): + key = str(raw_key).strip().upper() + typed[key] = check_dim_value(key, value) + return typed + + +def resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict[str, Any]: + """The preset's seventeen values with ``overrides`` applied on top. + + ``preset`` ``None`` means the ISO-25 base. An unknown preset, an override + key outside :data:`DIM_VARIABLE_WHITELIST` or a value outside its range + raises ``ValueError`` (``TypeError`` for the wrong type) naming the key — + before anything is written, because nothing here writes. + """ + key = "iso-25" if preset is None else str(preset).strip().lower() + if key not in PRESETS: + raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(PRESETS)}") + values = dict(PRESETS[key]) + values.update(validate_overrides(overrides)) + return values + + +def describe_preset(name: str) -> dict: + """``{name, source, values, textstyle}`` for a preset, or ``ValueError``.""" + key = str(name).strip().lower() + if key not in PRESETS: + raise ValueError(f"preset: unknown value {name!r}; use one of {sorted(PRESETS)}") + return { + "name": key, + "source": PRESET_SOURCES[key], + "values": dict(PRESETS[key]), + "textstyle": PRESETS[key]["DIMTXSTY"], + } diff --git a/engineering/standards/mleaderstyles.py b/engineering/standards/mleaderstyles.py new file mode 100644 index 0000000..53ac0d1 --- /dev/null +++ b/engineering/standards/mleaderstyles.py @@ -0,0 +1,66 @@ +"""Multileader style presets: arrow, landing gap, text style and height. + +Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows +and lettering; ANSI: 3 mm), so a leader note and a dimension on the same +sheet read at one size. Only ezdxf can author an MLEADERSTYLE object; +ActiveX exposes no collection for it (see ``backends/contracts/styles.py``). +""" + +from __future__ import annotations + +import math +from typing import Any + +__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] + +MLEADER_PRESETS: dict[str, dict[str, Any]] = { + "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, + "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}, +} + +MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height") + +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {value!r} is not a finite number") + if number < 0.0 or (number == 0.0 and not allow_zero): + raise ValueError(f"{key}: {value!r} must be greater than 0") + return number + + +def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str, Any]: + """The preset's four values with ``overrides`` applied; refusals name the key.""" + key = str(preset).strip().lower() if preset is not None else "" + if key not in MLEADER_PRESETS: + raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(MLEADER_PRESETS)}") + values = dict(MLEADER_PRESETS[key]) + if overrides is None: + return values + if not isinstance(overrides, dict): + raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}") + for raw_key, value in overrides.items(): + name = str(raw_key).strip().lower() + if name not in MLEADER_KEYS: + raise ValueError( + f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}" + ) + if name == "text_style": + if not isinstance(value, str) or not value.strip(): + raise ValueError("text_style: a text style name cannot be empty") + bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError( + f"text_style: {value!r} contains characters DXF forbids in a name: {bad}" + ) + values[name] = value.strip() + elif name == "landing_gap": + values[name] = _positive(name, value, allow_zero=True) + else: + values[name] = _positive(name, value) + return values diff --git a/engineering/standards/textstyles.py b/engineering/standards/textstyles.py new file mode 100644 index 0000000..daf6e53 --- /dev/null +++ b/engineering/standards/textstyles.py @@ -0,0 +1,100 @@ +"""Text style presets: the four fonts the standards data refers to by name. + +``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique +degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape +file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the +general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face +ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font +*name*, so an unknown file is never refused here: ``resolve_font`` says +whether it is a bundled preset, the backend adds whether its font path has +it, and the caller sees ``font_resolved``. +""" + +from __future__ import annotations + +from pathlib import PurePath + +__all__ = [ + "MAX_OBLIQUE_DEG", + "TEXT_PRESETS", + "resolve_font", + "validate_textstyle", +] + +TEXT_PRESETS: dict[str, tuple[str, float, float]] = { + "ISOCP": ("isocp.shx", 1.0, 0.0), + "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), + "ARIAL": ("arial.ttf", 1.0, 0.0), + "ROMANS": ("romans.shx", 1.0, 0.0), +} + +#: AutoCAD's own limit for the STYLE command's obliquing angle. +MAX_OBLIQUE_DEG = 85.0 + +_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + + +def resolve_font(font: str) -> tuple[str, bool]: + """``(font_file, known)``: a preset name or file resolves to the bundled + file with ``known=True``; anything else is passed through with ``False``. + + Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem + (``isocp``), case-insensitively. Refuses an empty string or control + characters — the only inputs a DXF cannot hold. + """ + if not isinstance(font, str): + raise TypeError(f"font: expected a string, got {type(font).__name__}") + text = font.strip() + if not text: + raise ValueError("font: a font file name cannot be empty") + if any(ord(ch) < 32 for ch in text): + raise ValueError("font: control characters are refused") + upper = text.upper() + if upper in TEXT_PRESETS: + return TEXT_PRESETS[upper][0], True + for font_file, _width, _oblique in TEXT_PRESETS.values(): + if upper in (font_file.upper(), PurePath(font_file).stem.upper()): + return font_file, True + return text, False + + +def validate_textstyle( + name: str, + font: str, + height: float = 0.0, + width_factor: float = 1.0, + oblique_deg: float = 0.0, +) -> dict: + """The typed request both engines write, or a ``ValueError``/``TypeError`` + naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal; + the width factor must be positive; the oblique angle is AutoCAD's ±85°.""" + if not isinstance(name, str) or not name.strip(): + raise ValueError("name: a text style name cannot be empty") + clean = name.strip() + bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS) + if bad: + raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}") + font_file, known = resolve_font(font) + for key, value in ( + ("height", height), + ("width_factor", width_factor), + ("oblique_deg", oblique_deg), + ): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + if float(height) < 0.0: + raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'") + if float(width_factor) <= 0.0: + raise ValueError(f"width_factor: {width_factor!r} must be greater than 0") + if abs(float(oblique_deg)) > MAX_OBLIQUE_DEG: + raise ValueError( + f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees" + ) + return { + "name": clean, + "font_file": font_file, + "known": known, + "height": float(height), + "width_factor": float(width_factor), + "oblique_deg": float(oblique_deg), + } diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py new file mode 100644 index 0000000..3eb0321 --- /dev/null +++ b/tests/test_standards_presets.py @@ -0,0 +1,326 @@ +# tests/test_standards_presets.py +"""Provenance for the authored style presets, pinned by name. + +Every ISO-25 and ANSI value is asserted against the table in spec §4.1 — a +reader with ISO 129-1 / ISO 3098-1 / ASME Y14.2 open can check each line. +The refusals are pinned too: a value the standards data lets through is a +value a backend writes, so the data is where "nothing written on refusal" +starts. +""" + +from __future__ import annotations + +import pytest + +from engineering.standards.dimstyles import ( + DIM_VARIABLE_RANGES, + DIM_VARIABLE_WHITELIST, + LINEWEIGHT_CODES, + PRESET_VARIABLES, + PRESETS, + check_dim_value, + describe_preset, + resolve_dimstyle, + validate_overrides, +) +from engineering.standards.mleaderstyles import ( + MLEADER_KEYS, + MLEADER_PRESETS, + resolve_mleaderstyle, +) +from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle + +# ── ISO-25: ISO 129-1:2018 at the 2.5 mm row of ISO 3098-1 ────────────────── + +ISO_25_EXPECTED = { + "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": "ISOCP", + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + +# ── ANSI: ASME Y14.2-2014, metric values ──────────────────────────────────── + +ANSI_EXPECTED = { + "DIMTXT": 3.0, + "DIMASZ": 3.0, + "DIMEXE": 1.5, + "DIMEXO": 1.5, + "DIMGAP": 1.0, + "DIMTAD": 0, + "DIMTIH": 1, + "DIMTOH": 1, + "DIMDEC": 2, + "DIMDSEP": ".", + "DIMLUNIT": 2, + "DIMZIN": 8, + "DIMBLK": "", + "DIMTXSTY": "ROMANS", + "DIMLWD": -2, + "DIMLWE": -2, + "DIMSCALE": 1.0, +} + + +@pytest.mark.parametrize(("variable", "expected"), sorted(ISO_25_EXPECTED.items())) +def test_iso25_value_by_name(variable, expected): + assert PRESETS["iso-25"][variable] == expected + assert type(PRESETS["iso-25"][variable]) is type(expected) + + +@pytest.mark.parametrize(("variable", "expected"), sorted(ANSI_EXPECTED.items())) +def test_ansi_value_by_name(variable, expected): + assert PRESETS["ansi"][variable] == expected + assert type(PRESETS["ansi"][variable]) is type(expected) + + +def test_presets_set_exactly_the_seventeen_spec_variables(): + assert len(PRESET_VARIABLES) == 17 + for name, preset in PRESETS.items(): + assert tuple(preset) == PRESET_VARIABLES, name + + +def test_the_whitelist_is_the_seventeen_plus_the_sixteen_spec_extras(): + extras = set( + "DIMTOL DIMTP DIMTM DIMTOLJ DIMTFAC DIMLFAC DIMRND DIMATFIT " + "DIMTMOVE DIMCLRD DIMCLRE DIMCLRT DIMSAH DIMBLK1 DIMBLK2 DIMCEN".split() + ) + assert DIM_VARIABLE_WHITELIST == set(PRESET_VARIABLES) | extras + assert set(DIM_VARIABLE_RANGES) == DIM_VARIABLE_WHITELIST + + +def test_iso_lineweights_are_all_legal_codes(): + for mm in (0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0): + assert round(mm * 100) in LINEWEIGHT_CODES + + +def test_describe_preset_names_the_standard(): + iso = describe_preset("ISO-25") + assert iso["name"] == "iso-25" + assert "ISO 129-1" in iso["source"] and "ISO 3098-1" in iso["source"] + assert iso["values"] == ISO_25_EXPECTED and iso["textstyle"] == "ISOCP" + ansi = describe_preset("ansi") + assert "ASME Y14.2" in ansi["source"] and ansi["textstyle"] == "ROMANS" + with pytest.raises(ValueError, match="preset"): + describe_preset("din") + + +# ── resolve_dimstyle ──────────────────────────────────────────────────────── + + +def test_none_preset_is_the_iso25_base(): + assert resolve_dimstyle(None, None) == ISO_25_EXPECTED + assert resolve_dimstyle("ISO-25", {}) == ISO_25_EXPECTED + assert resolve_dimstyle("ansi", None) == ANSI_EXPECTED + + +def test_overrides_apply_on_top_and_are_upper_cased_and_typed(): + values = resolve_dimstyle("iso-25", {"dimtxt": 3, "DIMDEC": 3.0, "dimdsep": "."}) + assert values["DIMTXT"] == 3.0 and type(values["DIMTXT"]) is float + assert values["DIMDEC"] == 3 and type(values["DIMDEC"]) is int + assert values["DIMDSEP"] == "." + assert values["DIMASZ"] == 2.5, "untouched values stay the preset's" + + +def test_resolve_leaves_the_preset_table_untouched(): + resolve_dimstyle("iso-25", {"DIMTXT": 9.0}) + assert PRESETS["iso-25"]["DIMTXT"] == 2.5 + + +@pytest.mark.parametrize( + ("overrides", "fragment"), + [ + ({"DIMFOO": 1}, "DIMFOO"), + ({"DIMDEC": 9}, "DIMDEC"), + ({"DIMDEC": -1}, "DIMDEC"), + ({"DIMDEC": 2.5}, "whole number"), + ({"DIMTXT": 0}, "DIMTXT"), + ({"DIMASZ": -1}, "DIMASZ"), + ({"DIMTAD": 5}, "DIMTAD"), + ({"DIMTIH": 2}, "DIMTIH"), + ({"DIMDSEP": ",,"}, "DIMDSEP"), + ({"DIMDSEP": ""}, "DIMDSEP"), + ({"DIMLWD": 27}, "DIMLWD"), + ({"DIMLUNIT": 0}, "DIMLUNIT"), + ({"DIMZIN": 16}, "DIMZIN"), + ({"DIMTXSTY": ""}, "DIMTXSTY"), + ({"DIMTXSTY": "a/b"}, "DIMTXSTY"), + ({"DIMLFAC": 0}, "DIMLFAC"), + ({"DIMTFAC": 0}, "DIMTFAC"), + ({"DIMCLRT": 257}, "DIMCLRT"), + ({"DIMRND": -0.5}, "DIMRND"), + ({"DIMSCALE": float("nan")}, "DIMSCALE"), + ], +) +def test_out_of_range_or_unknown_overrides_are_refused_by_name(overrides, fragment): + with pytest.raises(ValueError, match=fragment): + resolve_dimstyle("iso-25", overrides) + + +@pytest.mark.parametrize( + ("overrides", "fragment"), + [ + ({"DIMTXT": "2.5"}, "DIMTXT"), + ({"DIMTAD": True}, "DIMTAD"), + ({"DIMDSEP": 44}, "DIMDSEP"), + ({"DIMBLK": 0}, "DIMBLK"), + ], +) +def test_wrong_types_are_a_type_error_naming_the_key(overrides, fragment): + with pytest.raises(TypeError, match=fragment): + resolve_dimstyle("iso-25", overrides) + + +def test_unknown_preset_is_refused(): + with pytest.raises(ValueError, match="preset"): + resolve_dimstyle("din", None) + with pytest.raises(TypeError, match="overrides"): + resolve_dimstyle("iso-25", [("DIMTXT", 2.5)]) + + +def test_validate_overrides_alone_is_what_modify_uses(): + assert validate_overrides(None) == {} + assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text" + assert check_dim_value("dimlwe", 25) == 25 + assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE" + + +# ── text styles ───────────────────────────────────────────────────────────── + + +def test_text_presets_are_the_four_spec_fonts(): + assert TEXT_PRESETS == { + "ISOCP": ("isocp.shx", 1.0, 0.0), + "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), + "ARIAL": ("arial.ttf", 1.0, 0.0), + "ROMANS": ("romans.shx", 1.0, 0.0), + } + + +@pytest.mark.parametrize( + ("font", "expected"), + [ + ("ISOCP", "isocp.shx"), + ("isocp", "isocp.shx"), + ("isocp.shx", "isocp.shx"), + ("ISOCP.SHX", "isocp.shx"), + ("ISOCPEUR", "isocpeur.ttf"), + ("arial", "arial.ttf"), + ("Arial.ttf", "arial.ttf"), + ("romans.shx", "romans.shx"), + ], +) +def test_a_preset_font_resolves_known(font, expected): + assert resolve_font(font) == (expected, True) + + +def test_an_unknown_font_passes_through_unknown_never_refused(): + assert resolve_font("simplex.shx") == ("simplex.shx", False) + assert resolve_font(" c:/fonts/custom.ttf ") == ("c:/fonts/custom.ttf", False) + + +def test_empty_or_control_font_names_are_refused(): + with pytest.raises(ValueError, match="font"): + resolve_font("") + with pytest.raises(ValueError, match="font"): + resolve_font("bad\nfont.shx") + with pytest.raises(TypeError, match="font"): + resolve_font(None) + + +def test_validate_textstyle_types_the_request(): + spec = validate_textstyle(" ISOCP ", "isocp", 0, 1, 0) + assert spec == { + "name": "ISOCP", + "font_file": "isocp.shx", + "known": True, + "height": 0.0, + "width_factor": 1.0, + "oblique_deg": 0.0, + } + + +@pytest.mark.parametrize( + ("kwargs", "fragment"), + [ + ({"name": ""}, "name"), + ({"name": "A:B"}, "name"), + ({"height": -1}, "height"), + ({"width_factor": 0}, "width_factor"), + ({"oblique_deg": 86}, "oblique_deg"), + ({"oblique_deg": -90}, "oblique_deg"), + ], +) +def test_validate_textstyle_refuses_by_field(kwargs, fragment): + base = { + "name": "T", + "font": "arial.ttf", + "height": 0.0, + "width_factor": 1.0, + "oblique_deg": 0.0, + } + base.update(kwargs) + with pytest.raises(ValueError, match=fragment): + validate_textstyle(**base) + + +def test_validate_textstyle_refuses_wrong_types(): + with pytest.raises(TypeError, match="width_factor"): + validate_textstyle("T", "arial.ttf", 0.0, "1", 0.0) + with pytest.raises(TypeError, match="height"): + validate_textstyle("T", "arial.ttf", True, 1.0, 0.0) + + +# ── multileader styles ────────────────────────────────────────────────────── + + +def test_mleader_presets_match_the_plan(): + assert MLEADER_PRESETS == { + "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, + "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}, + } + assert MLEADER_KEYS == ("arrow_size", "landing_gap", "text_style", "text_height") + + +def test_resolve_mleaderstyle_applies_overrides(): + values = resolve_mleaderstyle("ISO", {"arrow_size": 3, "text_style": " ARIAL "}) + assert values == { + "arrow_size": 3.0, + "landing_gap": 1.0, + "text_style": "ARIAL", + "text_height": 2.5, + } + assert resolve_mleaderstyle("ansi") == MLEADER_PRESETS["ansi"] + assert resolve_mleaderstyle("iso", {"landing_gap": 0})["landing_gap"] == 0.0 + + +@pytest.mark.parametrize( + ("preset", "overrides", "exc", "fragment"), + [ + ("din", None, ValueError, "preset"), + (None, None, ValueError, "preset"), + ("iso", {"colour": 1}, ValueError, "colour"), + ("iso", {"arrow_size": 0}, ValueError, "arrow_size"), + ("iso", {"text_height": -2}, ValueError, "text_height"), + ("iso", {"text_style": ""}, ValueError, "text_style"), + ("iso", {"text_style": "a|b"}, ValueError, "text_style"), + ("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"), + ("iso", ["arrow_size"], TypeError, "overrides"), + ], +) +def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment): + with pytest.raises(exc, match=fragment): + resolve_mleaderstyle(preset, overrides) From 827719fdd0fdc4c1c9130c78b8f17d7d233b53db Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:33:05 +0300 Subject: [PATCH 02/13] fix(task-9): finite text-style numbers, one symbol-name rule, canonical DIMBLK arrowheads Three review findings on the standards data layer, each a value the layer let through that a backend would then write as a silent wrong number or a drawing that can no longer be saved: * validate_textstyle never checked math.isfinite, unlike its siblings; nan and inf passed every comparison and reached the STYLE record verbatim. It now has the same _number helper dimstyles and mleaderstyles use. * The three private _ILLEGAL_NAME_CHARS copies omitted \n \r \t that security._FORBIDDEN_SYMBOL_CHARS holds, so 'a\nb' was accepted as a text style, DIMTXSTY or leader text style and ezdxf refused the save. The rule now lives once in engineering/standards/names.py (fastmcp-free), the three modules import it, and a test pins its character set equal to security's and its output equal to illegal_symbol_name_chars. * DIMBLK/DIMBLK1/DIMBLK2 were ("str",): any string went through, and the test pinned '_OBLIQUE', which ezdxf's writer refuses at doc.write while the COM engine wants exactly that spelling. They are now ("arrow",): canonical_arrowhead accepts a built-in with or without its underscore in any case (and '', '.', CLOSEDFILLED for the closed filled default), emits AutoCAD's documented block-name spelling (also what ezdxf reports after a load), and holds user block names to the symbol-name rule; ARROWHEAD_BLOCKS is pinned equal to ezdxf's ARROWS vocabulary, and ezdxf_arrowhead is the one translation the headless writer needs, proved by exporting all twenty headlessly and reading each back as its canonical name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/standards/__init__.py | 6 ++ engineering/standards/dimstyles.py | 99 +++++++++++++++----- engineering/standards/mleaderstyles.py | 13 +-- engineering/standards/names.py | 38 ++++++++ engineering/standards/textstyles.py | 54 ++++++----- tests/test_standards_presets.py | 124 +++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 engineering/standards/names.py diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py index cac391e..90b4f1a 100644 --- a/engineering/standards/__init__.py +++ b/engineering/standards/__init__.py @@ -10,12 +10,15 @@ the tools and the provenance tests read one copy. from __future__ import annotations from engineering.standards.dimstyles import ( + ARROWHEAD_BLOCKS, DIM_VARIABLE_RANGES, DIM_VARIABLE_WHITELIST, PRESET_VARIABLES, PRESETS, + canonical_arrowhead, check_dim_value, describe_preset, + ezdxf_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -23,14 +26,17 @@ from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleader from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle __all__ = [ + "ARROWHEAD_BLOCKS", "DIM_VARIABLE_RANGES", "DIM_VARIABLE_WHITELIST", "MLEADER_PRESETS", "PRESETS", "PRESET_VARIABLES", "TEXT_PRESETS", + "canonical_arrowhead", "check_dim_value", "describe_preset", + "ezdxf_arrowhead", "resolve_dimstyle", "resolve_font", "resolve_mleaderstyle", diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py index 8e81e42..e6b5f1a 100644 --- a/engineering/standards/dimstyles.py +++ b/engineering/standards/dimstyles.py @@ -25,8 +25,11 @@ from __future__ import annotations import math from typing import Any +from engineering.standards.names import check_name + __all__ = [ "ANSI", + "ARROWHEAD_BLOCKS", "DIM_VARIABLE_RANGES", "DIM_VARIABLE_WHITELIST", "ISO_25", @@ -34,8 +37,10 @@ __all__ = [ "PRESETS", "PRESET_SOURCES", "PRESET_VARIABLES", + "canonical_arrowhead", "check_dim_value", "describe_preset", + "ezdxf_arrowhead", "resolve_dimstyle", "validate_overrides", ] @@ -122,6 +127,23 @@ _LINEWEIGHT_TEXT = ( ) LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split()) +#: AutoCAD's nineteen named built-in arrowheads, spelled the way the DIMBLK +#: documentation, the ``$DIMBLK`` header, ActiveX and a loaded ezdxf DIMSTYLE +#: all spell them: the block name, underscore first. The twentieth, closed +#: filled, has no block name and is ``""`` (the spec §4.1 default). +#: ``tests/test_standards_presets.py`` pins this set equal to ezdxf's +#: ``ARROWS.__acad__`` so the two vocabularies cannot drift apart unseen. +ARROWHEAD_BLOCKS: frozenset[str] = frozenset( + "_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED " + "_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 " + "_ORIGIN _ORIGIN2 _SMALL".split() +) + +#: The spellings of the closed filled default a caller may send: AutoCAD's own +#: "enter a single period to return to closed filled", and the block name +#: ezdxf gives the arrow it draws for it. +_CLOSED_FILLED_ALIASES = frozenset({".", "CLOSEDFILLED", "_CLOSEDFILLED"}) + #: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each #: value must sit in. The shape of each entry: #: @@ -131,7 +153,9 @@ LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_T #: * ``("int", low, high)`` — an integer (a float with no fraction is accepted). #: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused. #: * ``("char",)`` — exactly one character. -#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow). +#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of +#: :data:`ARROWHEAD_BLOCKS` given with or without its underscore in any case, +#: or a user block name under the symbol-name rule; see :func:`canonical_arrowhead`. #: * ``("name",)`` — a non-empty DXF symbol-table name. #: * a ``set`` — one of its members. DIM_VARIABLE_RANGES: dict[str, tuple | set] = { @@ -148,7 +172,7 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMDSEP": ("char",), "DIMLUNIT": ("int", 1, 6), "DIMZIN": ("int", 0, 15), # a bitmask - "DIMBLK": ("str",), + "DIMBLK": ("arrow",), "DIMTXSTY": ("name",), "DIMLWD": LINEWEIGHT_CODES, "DIMLWE": LINEWEIGHT_CODES, @@ -167,17 +191,56 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMCLRE": ("int", 0, 256), "DIMCLRT": ("int", 0, 256), "DIMSAH": {0, 1}, - "DIMBLK1": ("str",), - "DIMBLK2": ("str",), + "DIMBLK1": ("arrow",), + "DIMBLK2": ("arrow",), "DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none } DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES) -#: DXF forbids these in a symbol-table name; the same rule ``security.py`` -#: applies to block and layer names, restated here so this module stays free -#: of fastmcp. -_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + +def canonical_arrowhead(key: str, value: Any) -> str: + """``value`` as the arrowhead name DIMBLK stores, or ``TypeError`` / + ``ValueError`` naming ``key``. + + ``""``, ``"."``, ``CLOSEDFILLED`` and ``_CLOSEDFILLED`` are the closed + filled default and come back as ``""``. A built-in is accepted with or + without its underscore in any case (``oblique``, ``_Dot``) and comes back + as its block name (``_OBLIQUE``, ``_DOT``) — AutoCAD's documented spelling, + what ActiveX takes verbatim and what ezdxf reports after a load. Anything + else is a user block name and must obey the symbol-name rule; whether the + block exists is the backend's question, asked against the open drawing. + """ + if not isinstance(value, str): + raise TypeError(f"{key}: expected a string, got {type(value).__name__}") + if value == "": + return "" + text = value.strip() + if text.upper() in _CLOSED_FILLED_ALIASES: + return "" + if not text: + raise ValueError( + f'{key}: {value!r} is blank; use "" for the closed filled arrowhead or a block name' + ) + upper = text.upper() + candidate = upper if upper.startswith("_") else "_" + upper + if candidate in ARROWHEAD_BLOCKS: + return candidate + return check_name(key, text, what="block name") + + +def ezdxf_arrowhead(canonical: str) -> str: + """The name ezdxf's DIMSTYLE takes for a :func:`canonical_arrowhead` value. + + ezdxf's ``ARROWS`` vocabulary drops the underscore (``_DOT`` → ``DOT``, + ``""`` stays ``""``) and its export raises ``DXFValueError`` on ``_DOT`` + because no block of that name exists until it creates one from ``DOT``. A + user block is passed through unchanged. The COM engine needs no mapping: + AutoCAD takes the canonical spelling. + """ + if canonical in ARROWHEAD_BLOCKS: + return canonical[1:] + return canonical def _number(key: str, value: Any) -> float: @@ -226,21 +289,15 @@ def check_dim_value(name: str, value: Any) -> Any: if not low <= number <= high: raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}") return int(number) + if kind == "arrow": + return canonical_arrowhead(key, value) + if kind == "name": + return check_name(key, value, what="style name") if not isinstance(value, str): raise TypeError(f"{key}: expected a string, got {type(value).__name__}") - if kind == "char": - if len(value) != 1: - raise ValueError(f"{key}: {value!r} must be exactly one character") - return value - if kind == "name": - text = value.strip() - if not text: - raise ValueError(f"{key}: a style name cannot be empty") - bad = sorted(set(text) & _ILLEGAL_NAME_CHARS) - if bad: - raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}") - return text - return value # "str" + if len(value) != 1: # "char" + raise ValueError(f"{key}: {value!r} must be exactly one character") + return value def validate_overrides(overrides: dict | None) -> dict[str, Any]: diff --git a/engineering/standards/mleaderstyles.py b/engineering/standards/mleaderstyles.py index 53ac0d1..48a386d 100644 --- a/engineering/standards/mleaderstyles.py +++ b/engineering/standards/mleaderstyles.py @@ -11,6 +11,8 @@ from __future__ import annotations import math from typing import Any +from engineering.standards.names import check_name + __all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] MLEADER_PRESETS: dict[str, dict[str, Any]] = { @@ -20,8 +22,6 @@ MLEADER_PRESETS: dict[str, dict[str, Any]] = { MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height") -_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') - def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): @@ -51,14 +51,7 @@ def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}" ) if name == "text_style": - if not isinstance(value, str) or not value.strip(): - raise ValueError("text_style: a text style name cannot be empty") - bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS) - if bad: - raise ValueError( - f"text_style: {value!r} contains characters DXF forbids in a name: {bad}" - ) - values[name] = value.strip() + values[name] = check_name(name, value, what="text style name") elif name == "landing_gap": values[name] = _positive(name, value, allow_zero=True) else: diff --git a/engineering/standards/names.py b/engineering/standards/names.py new file mode 100644 index 0000000..a1c610f --- /dev/null +++ b/engineering/standards/names.py @@ -0,0 +1,38 @@ +"""The DXF symbol-table name rule, in one place, for the standards data. + +``security.illegal_symbol_name_chars`` is the same rule at the tool layer; it +sits behind a fastmcp import and this package stays free of fastmcp, so the +rule is restated here and ``tests/test_standards_presets.py`` pins the two +character sets equal. A style, block or font name that passes here is one +ezdxf will save and AutoCAD will load; ezdxf's own check fires at +``doc.write``, long after a create tool has already said ``ok``. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["ILLEGAL_NAME_CHARS", "check_name", "illegal_name_chars"] + +#: Characters DXF forbids in a symbol-table name, plus the whitespace that +#: ends a SendCommand segment — byte-for-byte ``security._FORBIDDEN_SYMBOL_CHARS``. +ILLEGAL_NAME_CHARS: frozenset[str] = frozenset('<>/\\":;?*|,=`') | frozenset("\n\r\t") + + +def illegal_name_chars(name: str) -> list[str]: + """The characters in ``name`` a DXF symbol-table name cannot hold, sorted.""" + return sorted({c for c in name if c in ILLEGAL_NAME_CHARS or ord(c) < 32}) + + +def check_name(key: str, value: Any, *, what: str = "name") -> str: + """``value`` as a stripped, non-empty, legal symbol-table name, or a + ``TypeError`` / ``ValueError`` naming ``key`` (``what`` words the refusal).""" + if not isinstance(value, str): + raise TypeError(f"{key}: expected a string, got {type(value).__name__}") + text = value.strip() + if not text: + raise ValueError(f"{key}: a {what} cannot be empty") + bad = illegal_name_chars(text) + if bad: + raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}") + return text diff --git a/engineering/standards/textstyles.py b/engineering/standards/textstyles.py index daf6e53..8b2ea94 100644 --- a/engineering/standards/textstyles.py +++ b/engineering/standards/textstyles.py @@ -12,7 +12,11 @@ it, and the caller sees ``font_resolved``. from __future__ import annotations +import math from pathlib import PurePath +from typing import Any + +from engineering.standards.names import check_name __all__ = [ "MAX_OBLIQUE_DEG", @@ -31,7 +35,20 @@ TEXT_PRESETS: dict[str, tuple[str, float, float]] = { #: AutoCAD's own limit for the STYLE command's obliquing angle. MAX_OBLIQUE_DEG = 85.0 -_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`') + +def _number(key: str, value: Any) -> float: + """A finite number, or ``TypeError`` / ``ValueError`` naming ``key``. + + ``nan`` and ``inf`` pass every ``<`` / ``>`` check, and a backend writing + them verbatim leaves ``50\\nnan`` in the STYLE record — the silent wrong + number the contract forbids. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{key}: expected a number, got {type(value).__name__}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {value!r} is not a finite number") + return number def resolve_font(font: str) -> tuple[str, bool]: @@ -67,26 +84,21 @@ def validate_textstyle( ) -> dict: """The typed request both engines write, or a ``ValueError``/``TypeError`` naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal; - the width factor must be positive; the oblique angle is AutoCAD's ±85°.""" - if not isinstance(name, str) or not name.strip(): - raise ValueError("name: a text style name cannot be empty") - clean = name.strip() - bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS) - if bad: - raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}") + the width factor must be positive; the oblique angle is AutoCAD's ±85°; + every number must be finite. The name obeys the DXF symbol-name rule + (``engineering.standards.names``) — this is the only gate on the + ``textstyle_create`` path, and a name that passes must be one the drawing + can still be saved with.""" + clean = check_name("name", name, what="text style name") font_file, known = resolve_font(font) - for key, value in ( - ("height", height), - ("width_factor", width_factor), - ("oblique_deg", oblique_deg), - ): - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError(f"{key}: expected a number, got {type(value).__name__}") - if float(height) < 0.0: + height_value = _number("height", height) + width_value = _number("width_factor", width_factor) + oblique_value = _number("oblique_deg", oblique_deg) + if height_value < 0.0: raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'") - if float(width_factor) <= 0.0: + if width_value <= 0.0: raise ValueError(f"width_factor: {width_factor!r} must be greater than 0") - if abs(float(oblique_deg)) > MAX_OBLIQUE_DEG: + if abs(oblique_value) > MAX_OBLIQUE_DEG: raise ValueError( f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees" ) @@ -94,7 +106,7 @@ def validate_textstyle( "name": clean, "font_file": font_file, "known": known, - "height": float(height), - "width_factor": float(width_factor), - "oblique_deg": float(oblique_deg), + "height": height_value, + "width_factor": width_value, + "oblique_deg": oblique_value, } diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index 3eb0321..cacd97f 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -13,6 +13,7 @@ from __future__ import annotations import pytest from engineering.standards.dimstyles import ( + ARROWHEAD_BLOCKS, DIM_VARIABLE_RANGES, DIM_VARIABLE_WHITELIST, LINEWEIGHT_CODES, @@ -20,6 +21,7 @@ from engineering.standards.dimstyles import ( PRESETS, check_dim_value, describe_preset, + ezdxf_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -158,6 +160,12 @@ def test_resolve_leaves_the_preset_table_untouched(): ({"DIMZIN": 16}, "DIMZIN"), ({"DIMTXSTY": ""}, "DIMTXSTY"), ({"DIMTXSTY": "a/b"}, "DIMTXSTY"), + ({"DIMTXSTY": "a\nb"}, "DIMTXSTY"), + ({"DIMTXSTY": "a\tb"}, "DIMTXSTY"), + ({"DIMBLK": "a/b"}, "DIMBLK"), + ({"DIMBLK": "a,b"}, "DIMBLK"), + ({"DIMBLK1": "x\ny"}, "DIMBLK1"), + ({"DIMBLK2": " "}, "DIMBLK2"), ({"DIMLFAC": 0}, "DIMLFAC"), ({"DIMTFAC": 0}, "DIMTFAC"), ({"DIMCLRT": 257}, "DIMCLRT"), @@ -198,6 +206,110 @@ def test_validate_overrides_alone_is_what_modify_uses(): assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE" +# ── arrowheads: DIMBLK / DIMBLK1 / DIMBLK2 ────────────────────────────────── + + +def test_the_builtin_arrowheads_are_autocads_nineteen_named_blocks(): + assert ARROWHEAD_BLOCKS == frozenset( + "_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED " + "_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 " + "_ORIGIN _ORIGIN2 _SMALL".split() + ) + + +def test_the_builtin_arrowheads_are_exactly_ezdxfs_vocabulary(): + from ezdxf.render.arrows import ARROWS + + assert ARROWHEAD_BLOCKS == {ARROWS.block_name(name) for name in ARROWS.__acad__ if name} + assert ARROWS.block_name("") == "_CLOSEDFILLED", "closed filled is the nameless default" + + +@pytest.mark.parametrize( + ("given", "canonical"), + [ + ("", ""), + (".", ""), + ("CLOSEDFILLED", ""), + ("_ClosedFilled", ""), + ("_OBLIQUE", "_OBLIQUE"), + ("OBLIQUE", "_OBLIQUE"), + ("oblique", "_OBLIQUE"), + (" _dot ", "_DOT"), + ("Open30", "_OPEN30"), + ("none", "_NONE"), + ("MYARROW", "MYARROW"), + (" my_arrow-1 ", "my_arrow-1"), + ], +) +def test_arrowhead_names_canonicalise_to_autocads_documented_spelling(given, canonical): + for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"): + assert check_dim_value(var, given) == canonical, var + + +@pytest.mark.parametrize("bad", ["a/b", "a,b", "x\ny", "x\ty", "x\rx", "a\x01b", " ", "\t"]) +def test_arrowhead_user_blocks_obey_the_symbol_name_rule(bad): + for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"): + with pytest.raises(ValueError, match=var): + check_dim_value(var, bad) + + +def test_ezdxf_arrowhead_strips_the_underscore_only_for_built_ins(): + assert ezdxf_arrowhead("") == "" + assert ezdxf_arrowhead("_OBLIQUE") == "OBLIQUE" + assert ezdxf_arrowhead("_DOT") == "DOT" + assert ezdxf_arrowhead("MYARROW") == "MYARROW" + assert ezdxf_arrowhead("_MYBLOCK") == "_MYBLOCK", ( + "a user block with an underscore is not built in" + ) + + +@pytest.mark.parametrize("canonical", [*sorted(ARROWHEAD_BLOCKS), ""]) +def test_every_canonical_arrowhead_exports_headlessly_and_reads_back_as_itself(canonical): + """The canonical spelling is what ezdxf itself reports after a load; only + its *writer* wants the underscore-less name, and ``ezdxf_arrowhead`` is that + one translation.""" + import io + + import ezdxf + + doc = ezdxf.new("R2018") + style = doc.dimstyles.new("X") + style.dxf.dimblk = ezdxf_arrowhead(canonical) + style.dxf.dimblk1 = ezdxf_arrowhead(canonical) + buffer = io.StringIO() + doc.write(buffer) + buffer.seek(0) + reloaded = ezdxf.read(buffer).dimstyles.get("X") + assert reloaded.dxf.dimblk == canonical + assert reloaded.dxf.dimblk1 == canonical + + +def test_the_canonical_spelling_is_not_what_the_headless_writer_takes(): + """Why ``ezdxf_arrowhead`` exists: ``_OBLIQUE`` stored verbatim raises at + ``doc.write``, after ``dimstyle_create`` would already have said ``ok``.""" + import io + + import ezdxf + from ezdxf.lldxf.const import DXFValueError + + doc = ezdxf.new("R2018") + doc.dimstyles.new("X").dxf.dimblk = "_OBLIQUE" + with pytest.raises(DXFValueError, match="_OBLIQUE"): + doc.write(io.StringIO()) + + +# ── the symbol-name rule ──────────────────────────────────────────────────── + + +def test_the_name_rule_is_the_one_security_applies(): + import security + from engineering.standards.names import ILLEGAL_NAME_CHARS, illegal_name_chars + + assert ILLEGAL_NAME_CHARS == frozenset(security._FORBIDDEN_SYMBOL_CHARS) + for sample in ("a\nb", "a\tb", "a\rb", "a\x01b", "a/b", "ac", "ok_name-1", "ISO-25"): + assert illegal_name_chars(sample) == security.illegal_symbol_name_chars(sample), sample + + # ── text styles ───────────────────────────────────────────────────────────── @@ -258,10 +370,19 @@ def test_validate_textstyle_types_the_request(): [ ({"name": ""}, "name"), ({"name": "A:B"}, "name"), + ({"name": "a\nb"}, "name"), + ({"name": "a\tb"}, "name"), + ({"name": "a\rb"}, "name"), ({"height": -1}, "height"), + ({"height": float("nan")}, "height"), + ({"height": float("inf")}, "height"), ({"width_factor": 0}, "width_factor"), + ({"width_factor": float("nan")}, "width_factor"), + ({"width_factor": float("inf")}, "width_factor"), ({"oblique_deg": 86}, "oblique_deg"), ({"oblique_deg": -90}, "oblique_deg"), + ({"oblique_deg": float("nan")}, "oblique_deg"), + ({"oblique_deg": float("-inf")}, "oblique_deg"), ], ) def test_validate_textstyle_refuses_by_field(kwargs, fragment): @@ -317,6 +438,9 @@ def test_resolve_mleaderstyle_applies_overrides(): ("iso", {"text_height": -2}, ValueError, "text_height"), ("iso", {"text_style": ""}, ValueError, "text_style"), ("iso", {"text_style": "a|b"}, ValueError, "text_style"), + ("iso", {"text_style": "a\nb"}, ValueError, "text_style"), + ("iso", {"text_style": "a\tb"}, ValueError, "text_style"), + ("iso", {"arrow_size": float("nan")}, ValueError, "arrow_size"), ("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"), ("iso", ["arrow_size"], TypeError, "overrides"), ], From 2e2cd2773db1123a7665f600c8539e7c7ee25b3d Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:52:01 +0300 Subject: [PATCH 03/13] fix(task-9): signed DIMTP/DIMTM, read-side arrowhead spelling, and the Task 10 handshake Two blocking review findings on the standards data layer: * DIMTP and DIMTM were pinned to ("float", 0.0, 1e6), refusing every signed deviation with a false "must be at least 0". AutoCAD documents both as signed and the repo already relies on it: build_dim_override writes dimtm=-0.022 for ISO 286 p6 and dimtp=-0.020 for f7, and dimension_linear(fit=...) emits exactly those. A dimension style could therefore never hold a double-positive or double-negative fit that the same server draws correctly per dimension, and an agent obeying the message would ship a sign-flipped tolerance. Both are now ("float", -1e6, 1e6); a provenance test resolves the p6/f7/H7/h6 overrides build_dim_override emits through resolve_dimstyle and pins the signs. * 827719f made check_dim_value return DIMBLK/DIMBLK1/DIMBLK2 in the canonical _OBLIQUE spelling and added ezdxf_arrowhead without telling Task 10, whose plan helpers store the value verbatim and read the raw string back - executed as written the headless drawing raised at render and at doc.write, a user block passed the data layer and failed at save, and ezdxf's OBLIQUE-in-session / _OBLIQUE-after-reload split made an idempotent re-set report a change (spec section 8.3). This commit adds the read-side twin reported_arrowhead (never raises; a foreign name passes through), pins it with an in-session-equals-reloaded round trip, and edits the plan: Task 9 Produces and Task 10 Consumes now name ARROWHEAD_BLOCKS, canonical_arrowhead, ezdxf_arrowhead and reported_arrowhead; Task 10's _write_dimstyle_values maps through ezdxf_arrowhead, _dimstyle_value and _com_dimvar_for_report map through reported_arrowhead, a new _require_arrowhead_blocks refuses a user block absent from doc.blocks before dimstyle_create/modify write anything (and rewrites the name to the block table's own spelling, measured to be what a reload reports), and two headless tests witness the rendered INSERTs, the save/reopen spelling and the refusal. The plan's own copy of the range table is corrected to match the module (arrow kind, signed tolerances). The edited helper block was executed against ezdxf 1.4.4 before being written into the plan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- ...2026-09-16-v1.6-settings-implementation.md | 129 ++++++++++++++++-- engineering/standards/__init__.py | 2 + engineering/standards/dimstyles.py | 31 ++++- tests/test_standards_presets.py | 87 ++++++++++++ 4 files changed, 236 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md index cb2b59e..9de3233 100644 --- a/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md +++ b/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md @@ -3351,7 +3351,7 @@ Part of `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`. Its - Test: `tests/test_standards_presets.py` **Interfaces:** -- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`. +- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; the arrowhead vocabulary (added by the Task 9 review fixes and consumed by Task 10's helpers below): `ARROWHEAD_BLOCKS: frozenset` (AutoCAD's nineteen named built-ins, `_OBLIQUE` spelling, pinned equal to ezdxf's `ARROWS.__acad__`), `canonical_arrowhead(key, value) -> str` (what `check_dim_value` returns for `DIMBLK`/`DIMBLK1`/`DIMBLK2`: `""` for closed filled and its aliases `.`/`CLOSEDFILLED`/`_CLOSEDFILLED`, a built-in in any case with or without its underscore → its block name, anything else a user block name under the symbol-name rule — whether that block *exists* is the backend's check against `doc.blocks`, before any write), `ezdxf_arrowhead(canonical) -> str` (the one translation the headless **writer** needs: `_OBLIQUE` → `OBLIQUE`, user blocks and `""` unchanged — ezdxf raises `DXFValueError: Block "_OBLIQUE" does not exist` at `doc.write` and `DXFUndefinedBlockError` at render if the canonical spelling is stored verbatim), `reported_arrowhead(raw) -> str` (the **read** side, never raises: ezdxf reports `OBLIQUE` in-session and `_OBLIQUE` after a reload, COM reports `_OBLIQUE`; both come back as the canonical name, a foreign name passes through); `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`. - Every refusal is `ValueError` (wrong type: `TypeError`) naming the key, and nothing in this package writes anything — the backends call these before they touch a table. - Groups C and P add `sysvars.py`, `papers.py`, `pdfinfo.py`, `templates.py`, `plot.py` to the same package and import them by path; this `__init__.py` re-exports only group S's names, so those groups never need to edit it. @@ -3821,7 +3821,9 @@ LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_T #: * ``("int", low, high)`` — an integer (a float with no fraction is accepted). #: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused. #: * ``("char",)`` — exactly one character. -#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow). +#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of ``ARROWHEAD_BLOCKS`` +#: given with or without its underscore in any case, or a user block name under the +#: symbol-name rule; ``check_dim_value`` returns ``canonical_arrowhead(key, value)``. #: * ``("name",)`` — a non-empty DXF symbol-table name. #: * a ``set`` — one of its members. DIM_VARIABLE_RANGES: dict[str, tuple | set] = { @@ -3838,15 +3840,20 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMDSEP": ("char",), "DIMLUNIT": ("int", 1, 6), "DIMZIN": ("int", 0, 15), # a bitmask - "DIMBLK": ("str",), + "DIMBLK": ("arrow",), "DIMTXSTY": ("name",), "DIMLWD": LINEWEIGHT_CODES, "DIMLWE": LINEWEIGHT_CODES, "DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport" # tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist "DIMTOL": {0, 1}, - "DIMTP": ("float", 0.0, 1e6), - "DIMTM": ("float", 0.0, 1e6), + # Both signed, as AutoCAD documents them: the displayed lower deviation is + # -DIMTM, so a double-positive fit (ISO 286 p6, +0.035/+0.022) stores + # DIMTM -0.022 and a double-negative one (f7, -0.020/-0.041) stores DIMTP + # -0.020 — exactly what engineering/tolerances.py::build_dim_override emits + # per dimension; a style must be able to hold the same numbers. + "DIMTP": ("float", -1e6, 1e6), + "DIMTM": ("float", -1e6, 1e6), "DIMTOLJ": ("int", 0, 2), "DIMTFAC": ("float", 1e-9, 1e6), "DIMLFAC": ("nonzero_float", -1e6, 1e6), @@ -3857,8 +3864,8 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMCLRE": ("int", 0, 256), "DIMCLRT": ("int", 0, 256), "DIMSAH": {0, 1}, - "DIMBLK1": ("str",), - "DIMBLK2": ("str",), + "DIMBLK1": ("arrow",), + "DIMBLK2": ("arrow",), "DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none } @@ -4224,7 +4231,7 @@ git commit -m "feat(standards): ISO-25 and ANSI dimension, text and leader style - Test: `tests/test_styles.py` **Interfaces:** -- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9); `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM). +- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `ARROWHEAD_BLOCKS`, `ezdxf_arrowhead`, `reported_arrowhead`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9) — `validate_overrides` already returns `DIMBLK`/`DIMBLK1`/`DIMBLK2` in the canonical `_OBLIQUE` spelling, which ActiveX takes verbatim but ezdxf's writer refuses, so the headless helpers translate on write (`ezdxf_arrowhead`), canonicalise on read (`reported_arrowhead`, both engines) and refuse a user block absent from `doc.blocks` before any write; `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM). - Produces: the nine `StylesContract` methods on both backends, exactly the plan's signatures. Extra keys beyond the plan's row shapes, on both engines: `dimstyle_list` rows carry `values_available: bool`; `dimstyle_create` carries `written: [vars]`; `dimstyle_set_current` / `textstyle_set_current` carry `changed: bool`; `mleaderstyle_create` carries `textstyle_created`. Names are matched case-insensitively (AutoCAD's rule) and reported in the table's own spelling. - The COM rule, stated once in the code and enforced by the fake: `AcadDimStyle` has `Name` and `CopyFrom` only. Values are written by setting the `DIM*` document variables and `CopyFrom(document)`; making a style current (`ActiveDimStyle`) restores its saved settings and drops unsaved overrides; reading a non-current style's values is impossible, so `dimstyle_list` reports `values` for the current style only. `mleaderstyle_create` stays the `@capability("mleaderstyle")` default on COM (never defined there); `mleaderstyle_list` reads names from the `ACAD_MLEADERSTYLE` dictionary with `values_available: false`. @@ -4368,6 +4375,46 @@ async def test_refusals_happen_before_anything_is_written(backend): 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") @@ -5019,8 +5066,14 @@ def _dimstyle_schema_default(var: str): 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).""" + """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: @@ -5030,6 +5083,12 @@ def _dimstyle_value(style, var: str): 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 @@ -5039,19 +5098,58 @@ def _read_dimstyle_values(style, variables=None) -> dict[str, Any]: 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 - style.dxf.set(attr, ord(value) if var == "DIMDSEP" else value) + 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: @@ -5179,6 +5277,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly "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" ) @@ -5225,6 +5324,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly 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)) @@ -5458,7 +5558,14 @@ def _com_dimvar_for_report(var: str, raw: Any) -> Any: return "," if raw == 0 else chr(raw) text = str(raw) return text[:1] if text else "," - if var in ("DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"): + 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 diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py index 90b4f1a..abb8477 100644 --- a/engineering/standards/__init__.py +++ b/engineering/standards/__init__.py @@ -19,6 +19,7 @@ from engineering.standards.dimstyles import ( check_dim_value, describe_preset, ezdxf_arrowhead, + reported_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -37,6 +38,7 @@ __all__ = [ "check_dim_value", "describe_preset", "ezdxf_arrowhead", + "reported_arrowhead", "resolve_dimstyle", "resolve_font", "resolve_mleaderstyle", diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py index e6b5f1a..f7ce151 100644 --- a/engineering/standards/dimstyles.py +++ b/engineering/standards/dimstyles.py @@ -41,6 +41,7 @@ __all__ = [ "check_dim_value", "describe_preset", "ezdxf_arrowhead", + "reported_arrowhead", "resolve_dimstyle", "validate_overrides", ] @@ -179,8 +180,13 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = { "DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport" # tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist "DIMTOL": {0, 1}, - "DIMTP": ("float", 0.0, 1e6), - "DIMTM": ("float", 0.0, 1e6), + # Both signed, as AutoCAD documents them: the displayed lower deviation is + # -DIMTM, so a double-positive fit (ISO 286 p6, +0.035/+0.022) stores + # DIMTM -0.022 and a double-negative one (f7, -0.020/-0.041) stores DIMTP + # -0.020 — exactly what engineering/tolerances.py::build_dim_override emits + # per dimension; a style must be able to hold the same numbers. + "DIMTP": ("float", -1e6, 1e6), + "DIMTM": ("float", -1e6, 1e6), "DIMTOLJ": ("int", 0, 2), "DIMTFAC": ("float", 1e-9, 1e6), "DIMLFAC": ("nonzero_float", -1e6, 1e6), @@ -229,6 +235,27 @@ def canonical_arrowhead(key: str, value: Any) -> str: return check_name(key, text, what="block name") +def reported_arrowhead(raw: Any) -> str: + """A *stored* arrowhead name in the canonical spelling, never raising. + + The read-side twin of :func:`canonical_arrowhead`: ezdxf reports the name + the writer stored (``OBLIQUE``) in-session and the block name + (``_OBLIQUE``) after a reload, so a raw read would spell one style two + ways and make re-setting a value to itself look like a change (spec §8.3). + A built-in in any spelling comes back as its block name and the closed + filled aliases as ``""``; ``None`` is ``""``; anything else — a user block, + or a foreign drawing's name the symbol-name rule would refuse — passes + through unchanged, because a read must not raise. + """ + if raw is None: + return "" + text = str(raw) + try: + return canonical_arrowhead("DIMBLK", text) + except (TypeError, ValueError): + return text + + def ezdxf_arrowhead(canonical: str) -> str: """The name ezdxf's DIMSTYLE takes for a :func:`canonical_arrowhead` value. diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index cacd97f..61def1a 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -22,6 +22,7 @@ from engineering.standards.dimstyles import ( check_dim_value, describe_preset, ezdxf_arrowhead, + reported_arrowhead, resolve_dimstyle, validate_overrides, ) @@ -206,6 +207,51 @@ def test_validate_overrides_alone_is_what_modify_uses(): assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE" +# ── tolerances: DIMTP / DIMTM are signed ──────────────────────────────────── + + +@pytest.mark.parametrize( + ("fit", "expected"), + [ + ("p6", {"DIMTP": 0.035, "DIMTM": -0.022}), # double positive: -DIMTM is +0.022 + ("f7", {"DIMTP": -0.02, "DIMTM": 0.041}), # double negative: DIMTP below zero + ("H7", {"DIMTP": 0.021, "DIMTM": 0.0}), + ("h6", {"DIMTP": 0.0, "DIMTM": 0.013}), + ], +) +def test_a_style_holds_the_same_signed_deviations_a_dimension_gets(fit, expected): + """``dimension_linear(fit=...)`` writes ``build_dim_override``'s DIMTP/DIMTM + onto one dimension; a dimension style must accept the identical numbers, + signs included, or a p6 / f7 fit can be drawn but never styled — and an + agent obeying an "at least 0" refusal would ship a sign-flipped tolerance.""" + from engineering.fits import fit_lookup + from engineering.tolerances import build_dim_override + + deviation = fit_lookup(fit, 20.0) + override, _ = build_dim_override(deviation.upper_mm, -deviation.lower_mm, "deviation", None) + styled = {k: v for k, v in override.items() if k.upper() in DIM_VARIABLE_WHITELIST} + assert {"dimtol", "dimtp", "dimtm"} <= set(styled) + typed = resolve_dimstyle("iso-25", styled) + assert typed["DIMTOL"] == 1 + assert typed["DIMTP"] == pytest.approx(expected["DIMTP"]) + assert typed["DIMTM"] == pytest.approx(expected["DIMTM"]) + assert typed["DIMTM"] == pytest.approx(override["dimtm"]), "sign preserved, not abs()" + assert typed["DIMTP"] == pytest.approx(override["dimtp"]) + + +@pytest.mark.parametrize("var", ["DIMTP", "DIMTM"]) +def test_tolerance_limits_are_signed_finite_floats(var): + assert DIM_VARIABLE_RANGES[var] == ("float", -1e6, 1e6) + assert check_dim_value(var, -0.022) == -0.022 + assert check_dim_value(var, 0) == 0.0 + with pytest.raises(ValueError, match=var): + check_dim_value(var, float("-inf")) + with pytest.raises(ValueError, match=var): + check_dim_value(var, -1e7) + with pytest.raises(TypeError, match=var): + check_dim_value(var, "-0.022") + + # ── arrowheads: DIMBLK / DIMBLK1 / DIMBLK2 ────────────────────────────────── @@ -298,6 +344,47 @@ def test_the_canonical_spelling_is_not_what_the_headless_writer_takes(): doc.write(io.StringIO()) +@pytest.mark.parametrize( + ("stored", "reported"), + [ + ("OBLIQUE", "_OBLIQUE"), # what ezdxf holds in-session after the writer set it + ("_OBLIQUE", "_OBLIQUE"), # what ezdxf holds after a reload; what COM reports + ("", ""), + (None, ""), + ("_CLOSEDFILLED", ""), + ("MYARROW", "MYARROW"), + ("MY ARROW,1", "MY ARROW,1"), # a foreign drawing's name: passed through, never raised + (" ", " "), + ], +) +def test_reported_arrowhead_spells_a_stored_name_one_way_and_never_raises(stored, reported): + assert reported_arrowhead(stored) == reported + + +@pytest.mark.parametrize("canonical", ["_OBLIQUE", "_DOT", "", "MYARROW"]) +def test_a_style_reports_the_same_arrowhead_before_and_after_a_reload(canonical): + """The spec §8.3 witness at the data layer: a raw read spells ``OBLIQUE`` + in-session and ``_OBLIQUE`` after reopen, so ``dimstyle_modify`` re-setting + the same value would report a change. Through ``reported_arrowhead`` the two + reads agree with each other and with what ``check_dim_value`` accepts.""" + import io + + import ezdxf + + doc = ezdxf.new("R2018") + if canonical == "MYARROW": + doc.blocks.new("MYARROW").add_line((0, 0), (1, 1)) + style = doc.dimstyles.new("X") + style.dxf.dimblk = ezdxf_arrowhead(canonical) + in_session = reported_arrowhead(style.dxf.dimblk) + buffer = io.StringIO() + doc.write(buffer) + buffer.seek(0) + reloaded = reported_arrowhead(ezdxf.read(buffer).dimstyles.get("X").dxf.dimblk) + assert in_session == reloaded == canonical + assert check_dim_value("DIMBLK", in_session) == canonical + + # ── the symbol-name rule ──────────────────────────────────────────────────── From cc3bd4876b8aa77ef72ccce134afa80604cb668f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:39:16 +0300 Subject: [PATCH 04/13] feat(styles): dimension, text and multileader styles on both engines; $DIMSTYLE/$TEXTSTYLE reach new entities headlessly Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/base.py | 2 + backends/com_backend.py | 368 +++++++++++++++++++ backends/contracts/__init__.py | 2 + backends/contracts/styles.py | 143 ++++++++ backends/ezdxf_backend.py | 482 +++++++++++++++++++++++- tests/test_styles.py | 643 +++++++++++++++++++++++++++++++++ 6 files changed, 1633 insertions(+), 7 deletions(-) create mode 100644 backends/contracts/styles.py create mode 100644 tests/test_styles.py 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 == [] From 2ee18727cd4645a1d552f75e675acea64ad9130d Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:00:17 +0300 Subject: [PATCH 05/13] fix(task-10): user dimstyles survive DXF round trips, purge keeps style references, Restore writes every DIM* variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three headless defects in the styles block, each measured before the fix: * `_normalise_dimstyle_rounding` cleaned `Standard` only, while ezdxf's exporter writes DIMRND 0.0 for every DIMSTYLE. A user style (ISO-25 / ANSI) came back from save/open, transaction_rollback and drawing_undo rounding every dimension to a whole unit ("33" for 33.333, "R6" for R6.35) with dimstyle_list still reporting DIMDEC 2. It now walks the whole table. * `drawing_purge` followed only entity `.dxf.style`, so it deleted the text style a DIMSTYLE (DIMTXSTY), an MLEADERSTYLE (text_style_handle) or the header ($TEXTSTYLE / $DIMTXSTY) referenced; the next dimension silently rendered in Standard and drawing_save_as raised DXFTableEntryError after leaving a truncated file. `_style_table_references` now counts those, plus the arrowhead blocks a DIMSTYLE / MLEADERSTYLE / MULTILEADER references, compared case-insensitively. * `_sync_header_to_dimstyle` wrote only the attributes the target style stores, leaving the previous style's $DIM* in the header — folded onto the next dimension by `_with_header_dimvars` (ISO-25 DIMZIN 0 / DIMSCALE 2.0 then Restore Standard rendered 5.0 mm "33.30") and written to the file as current-style overrides; $DIMRND 0.5 outlived the style's own discard. It now writes the schema default for every whitelist variable the style omits (`Standard` for DIMTXSTY), which reproduces a fresh header exactly. tests/test_styles.py: the reopen test now renders a dimension after the reopen, plus one test per defect; all four fail on the previous backend. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 130 +++++++++++++++++++++++++++++++----- tests/test_styles.py | 135 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index f9d244f..7729517 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -387,15 +387,26 @@ def _normalise_dimstyle_rounding(doc) -> None: itself — silently rounded every dimension to a whole unit *before* DIMDEC got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6. + Every style in the table, not `Standard` alone: ezdxf's exporter writes + group 45 = 0.0 for *each* DIMSTYLE entry, so a user style (the ISO-25 / + ANSI presets `dimstyle_create` makes) came back poisoned from every DXF + round trip — save/open, `transaction_rollback`, `drawing_undo` — and a + 33.333 mm feature dimensioned with it rendered "33" while `dimstyle_list` + still reported DIMDEC 2. Measured before this ran on the whole table. + Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm rounding keeps it. """ try: - style = doc.dimstyles.get("Standard") + styles = list(doc.dimstyles) except Exception: return - if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0: - style.dxf.discard("dimrnd") + for style in styles: + try: + if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0: + style.dxf.discard("dimrnd") + except (TypeError, ValueError): + continue def _apply_iso_dimstyle(doc) -> None: @@ -801,17 +812,42 @@ def _write_dimstyle_values(style, values: dict[str, Any]) -> None: def _sync_header_to_dimstyle(doc, style) -> None: - """Load the style's stored variables into ``$DIM*`` — a live seat's Restore.""" + """Load the style into ``$DIM*`` — every whitelist variable, a live seat's Restore. + + ``-DIMSTYLE Restore`` replaces *all* the DIM* system variables with the + style's settings; a DIMSTYLE entry that omits a code means its schema + default. Writing only the attributes the style *stores* left every other + ``$DIM*`` at the previous style's value — measured, ISO-25 with DIMZIN 0 / + DIMSCALE 2.0 made current and then ``dimstyle_set_current("Standard")`` + rendered the next dimension at 5.0 mm as "33.30": `_with_header_dimvars` + folded the stale header onto it, and the saved file carried the same + values next to ``$DIMSTYLE Standard``, which AutoCAD reads as overrides. + The same gap kept ``$DIMRND 0.5`` in the header after `dimstyle_modify` + discarded the style's own attribute. + + Measured on ezdxf 1.4.4: a fresh header equals the schema default for + every whitelist variable a fresh `Standard` does not store, so restoring + `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no + schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` + reports. + """ from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST for var in sorted(DIM_VARIABLE_WHITELIST): attr = var.lower() - if not style.dxf.hasattr(attr): + value = style.dxf.get(attr) if style.dxf.hasattr(attr) else None + if value is None: + value = _dimstyle_schema_default(var) + if value is None and var == "DIMTXSTY": + value = "Standard" + key = f"${var}" + if value is None: + try: + del doc.header[key] + except KeyError: + pass continue - raw = style.dxf.get(attr) - if raw is None: - continue - doc.header[f"${var}"] = raw + doc.header[key] = value def _font_available(font_file: str) -> bool: @@ -859,6 +895,57 @@ def _dimensions_using(doc, style_name: str) -> list[str]: return handles +def _style_table_references(doc) -> tuple[set[str], set[str]]: + """``(block names, text-style names)`` the style tables and header reference. + + Lower-cased, for `drawing_purge`. A DIMSTYLE names its text style and up + to four arrowhead blocks (stored in ezdxf's spelling, ``OBLIQUE``, while + the block is ``_OBLIQUE`` — both spellings are counted); an MLEADERSTYLE + references its text style and arrowhead by handle; the header names the + current text style and dimension text style. ``Standard`` is never purged. + """ + blocks: set[str] = set() + styles: set[str] = {"standard"} + + def _add_block(name) -> None: + if isinstance(name, str) and name: + lowered = name.lower() + blocks.add(lowered) + blocks.add(lowered if lowered.startswith("_") else f"_{lowered}") + + def _add_style(name) -> None: + if isinstance(name, str) and name: + styles.add(name.lower()) + + def _add_by_handle(handle, into) -> None: + entity = doc.entitydb.get(handle) if handle else None + if entity is not None: + into(entity.dxf.get("name")) + + try: + for var in ("$TEXTSTYLE", "$DIMTXSTY"): + _add_style(doc.header.get(var, None)) + for var in ("$DIMBLK", "$DIMBLK1", "$DIMBLK2", "$DIMLDRBLK"): + _add_block(doc.header.get(var, None)) + except Exception: # an opened document may not have a readable header + pass + for style in doc.dimstyles: + _add_style(style.dxf.get("dimtxsty")) + for attr in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"): + _add_block(style.dxf.get(attr)) + for _name, style in doc.mleader_styles: + _add_by_handle(style.dxf.get("text_style_handle"), _add_style) + _add_by_handle(style.dxf.get("arrow_head_handle"), _add_block) + for entity in doc.entitydb.values(): + if entity.dxftype() != "MULTILEADER": + continue + context = getattr(entity, "context", None) + _add_by_handle(getattr(context, "text_style_handle", None), _add_style) + for arrow in getattr(entity, "arrow_heads", None) or (): + _add_by_handle(getattr(arrow, "handle", None), _add_block) + return blocks, styles + + def _round_measure(result: dict) -> dict: """Six decimal places, matching analysis_measure_distance.""" result["area"] = round(float(result["area"]), 6) @@ -2538,17 +2625,31 @@ class EzdxfBackend(AutoCADBackend): raise self._solid_unsupported("solid_boolean") async def drawing_purge(self) -> dict: + """Delete every unreferenced block, layer, linetype and text style. + + A reference is not only an entity's own attribute. The style tables + reference each other — a DIMSTYLE names its text style (DIMTXSTY) and + its arrowhead blocks, an MLEADERSTYLE holds a text-style handle — and + the header names the current text style (``$TEXTSTYLE``, ``$DIMTXSTY``). + Measured before those were counted: ``dimstyle_create("ISO-25", …, + set_current=True)`` then ``drawing_purge()`` removed ISOCP, the next + dimension silently rendered in ``Standard`` while `dimstyle_list` still + said ISOCP, and ``drawing_save_as`` raised ``DXFTableEntryError`` after + leaving a truncated file on disk. Names are compared case-insensitively, + AutoCAD's rule for every symbol table. + """ + def _sync(): doc = self._require_doc() purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0} + used_blocks, used_styles = _style_table_references(doc) - used_blocks: set[str] = set() for ent in doc.entitydb.values(): if ent.dxftype() == "INSERT": - used_blocks.add(ent.dxf.name) + used_blocks.add(str(ent.dxf.name).lower()) for blk in list(doc.blocks): name = blk.name - if name.startswith("*") or name in used_blocks: + if name.startswith("*") or name.lower() in used_blocks: continue try: doc.blocks.delete_block(name, safe=True) @@ -2586,13 +2687,12 @@ class EzdxfBackend(AutoCADBackend): except Exception: pass - used_styles: set[str] = {"Standard"} for ent in doc.entitydb.values(): if hasattr(ent.dxf, "style"): - used_styles.add(ent.dxf.style) + used_styles.add(str(ent.dxf.style).lower()) for st in list(doc.styles): name = st.dxf.name - if name in used_styles: + if name.lower() in used_styles: continue try: doc.styles.remove(name) diff --git a/tests/test_styles.py b/tests/test_styles.py index 3aa989c..171e22e 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -12,8 +12,10 @@ from __future__ import annotations import types +import ezdxf import pytest +import config from backends.base import UnsupportedCapabilityError from backends.capability import is_capability_default from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name @@ -187,6 +189,139 @@ async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(ba 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"] == "," + # The table's report was never the thing that could lie; the render is. + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + + +async def test_a_user_dimstyle_survives_every_dxf_round_trip_without_rounding( + backend, tmp_path, monkeypatch +): + """ezdxf's exporter writes DIMRND 0.0 for *every* DIMSTYLE, and a stored 0.0 + makes the renderer round every dimension to a whole unit. Measured before + `_normalise_dimstyle_rounding` walked the whole table: ISO-25 came back from + a save/open, a `transaction_rollback` and a `drawing_undo` rendering 33.333 + as "33" and R6.35 as "R6" while `dimstyle_list` still reported DIMDEC 2.""" + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() # a history baseline with undo switched on + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None)) + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + + def _rounding_stored() -> dict[str, bool]: + return {s.dxf.name: s.dxf.hasattr("dimrnd") for s in backend._doc.dimstyles} + + path = str(tmp_path / "roundtrip.dxf") + await backend.drawing_save_as(path) + assert ezdxf.readfile(path).dimstyles.get("ISO-25").dxf.get("dimrnd") == 0.0, ( + "the premise: the file on disk carries the 0.0 sentinel for a user style" + ) + await backend.drawing_open(path) + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + reopened = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60) + assert _rendered(backend, reopened.handle) == ("33,33", 2.5, [2.5]) + radius = await backend.dimension_radius(50, 50, 56.35, 50) + assert _rendered(backend, radius.handle)[0] == "R6,35" + await backend.dimstyle_set_current("ANSI") + ansi = await backend.dimension_linear(0, 0, 12.75, 0, 6, -90) + assert _rendered(backend, ansi.handle)[0] == "12.75" + + await backend.dimstyle_set_current("ISO-25") + await backend.transaction_begin() + await backend.entity_create_line(0, 0, 10, 10) + await backend.transaction_rollback() + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + rolled_back = await backend.dimension_linear(0, 0, 33.333, 0, 16, -120) + assert _rendered(backend, rolled_back.handle) == ("33,33", 2.5, [2.5]) + + await backend.entity_create_line(0, 0, 20, 20) + assert (await backend.drawing_undo())["ok"] is True + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + undone = await backend.dimension_linear(0, 0, 33.333, 0, 16, -150) + assert _rendered(backend, undone.handle) == ("33,33", 2.5, [2.5]) + + await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5}) + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert backend._doc.dimstyles.get("ISO-25").dxf.dimrnd == 0.5, ( + "only the 0.0 sentinel is discarded; a rounding the drafter asked for survives" + ) + + +async def test_drawing_purge_keeps_what_the_style_tables_and_header_reference(backend, tmp_path): + """`drawing_purge` followed only entity `.dxf.style`; a text style that only + a DIMSTYLE, an MLEADERSTYLE or the header referenced was deleted, the next + dimension silently rendered in Standard, and `drawing_save_as` raised + `DXFTableEntryError` after leaving a truncated file on disk.""" + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + await backend.mleaderstyle_create("ANSI", resolve_mleaderstyle("ansi", None)) + await backend.textstyle_create("Notes", "arial.ttf", set_current=True) + await backend.textstyle_create("Junk", "isocpeur.ttf") + await backend.block_define("MYARROW", [{"type": "line", "x1": 0, "y1": 0, "x2": -1, "y2": 0.2}]) + await backend.dimstyle_create("ARROWED", resolve_dimstyle("ansi", {"DIMBLK": "MYARROW"})) + + result = await backend.drawing_purge() + assert result["purged"]["text_styles"] == 1, "only the style nothing references goes" + assert result["purged"]["blocks"] == 0 + names = {row["name"] for row in await backend.textstyle_list()} + assert {"Standard", "ISOCP", "ROMANS", "Notes"} <= names and "Junk" not in names + assert [r["name"] for r in await backend.textstyle_list() if r["current"]] == ["Notes"] + assert "MYARROW" in backend._doc.blocks + leader_styles = {r["name"]: r["text_style"] for r in await backend.mleaderstyle_list()} + assert leader_styles["ANSI"] == "ROMANS" + + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + path = str(tmp_path / "purged.dxf") + await backend.drawing_save_as(path) + reopened = ezdxf.readfile(path) + assert reopened.dimstyles.get("ISO-25").dxf.dimtxsty == "ISOCP" + assert reopened.header["$TEXTSTYLE"] == "Notes" + + +async def test_set_current_restores_every_dim_variable_not_just_the_stored_ones(backend, tmp_path): + """A live seat's `-DIMSTYLE Restore` replaces *all* DIM* variables. Writing + only what the target style stores left the previous style's values in the + header: measured, ISO-25 with DIMZIN 0 / DIMSCALE 2.0 then + `dimstyle_set_current("Standard")` rendered the next dimension at 5.0 mm as + "33.30" and the saved file carried those next to `$DIMSTYLE Standard`.""" + from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST + + header = backend._doc.header + fresh = {f"${var}": header.get(f"${var}", None) for var in DIM_VARIABLE_WHITELIST} + baseline = await backend.dimension_linear(0, 0, 33.3, 0, 16, -30) + + values = resolve_dimstyle("iso-25", {"DIMZIN": 0, "DIMSCALE": 2.0}) + await backend.dimstyle_create("ISO-25", values, set_current=True) + assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0 + assert header["$DIMTXSTY"] == "ISOCP" + result = await backend.dimstyle_set_current("Standard") + assert result["current"] == "Standard" + assert {key: header.get(key, None) for key in fresh} == fresh, ( + "restoring Standard must reproduce the header drawing_new made" + ) + dim = await backend.dimension_linear(0, 0, 33.3, 0, 16, -60) + expected = ("33.3", 2.5, [2.5]) + assert _rendered(backend, dim.handle) == _rendered(backend, baseline.handle) == expected + assert backend._doc.entitydb.get(dim.handle).override().dimstyle_attribs == {}, ( + "nothing stale is folded onto the dimension" + ) + + # DIMRND 0 discards the style attribute; the header twin must follow. + await backend.dimstyle_set_current("ISO-25") + assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0 + await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5}) + assert header["$DIMRND"] == 0.5 + cleared = await backend.dimstyle_modify("ISO-25", {"DIMRND": 0}) + assert cleared["changed"] == {"DIMRND": [0.5, 0]} + assert header["$DIMRND"] == 0.0 + await backend.dimstyle_set_current("Standard") + path = str(tmp_path / "restored.dxf") + await backend.drawing_save_as(path) + saved = ezdxf.readfile(path).header + assert saved["$DIMSTYLE"] == "Standard" + assert {key: saved.get(key, None) for key in fresh} == fresh async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend): From 4d246b17b81f4c77a547047c1d26d14fc7a97e50 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 14:52:57 +0300 Subject: [PATCH 06/13] fix(task-9): DIMDSEP refuses control characters and code points above the 16-bit DXF group check_dim_value's char branch only checked len == 1, so '\n', '\t', '\x00' and non-BMP characters passed and the headless writer stored ord(value) unguarded: '\n' split the dimension text over two lines, NUL read back as a comma, and U+1F600 reached $DIMDSEP / group 278 as 128512, outside the 16-bit range and refused by AutoCAD as "not a single character". ezdxf's audit caught none of it. The same hole 827719f closed for names and fonts, left open for the one variable stored as a character code. The gate now refuses code < 32, DEL, and code > DIMDSEP_MAX_CODE (0x7FFF, the signed short DWG stores) with a ValueError naming DIMDSEP, so nothing reaches a backend. Pinned by a parametrized refusal test (newline, tab, NUL, DEL, U+1F600, U+8000, U+FFFF), a positive test that the middle dot, the Arabic decimal separator and U+7FFF still pass, and the ezdxf refusal test now proves dimstyle_create / dimstyle_modify write nothing for these. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/standards/dimstyles.py | 29 ++++++++++++++++++++++++++--- tests/test_standards_presets.py | 29 +++++++++++++++++++++++++++++ tests/test_styles.py | 8 ++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py index f7ce151..4a63abc 100644 --- a/engineering/standards/dimstyles.py +++ b/engineering/standards/dimstyles.py @@ -12,9 +12,13 @@ Every value in ``PRESETS`` is pinned by name in lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads, text centred in a gap and horizontal, decimal point. -``DIMDSEP`` is a one-character *string* here. DXF stores the character code and -ezdxf raises on a string in the header, so the translation belongs to the -backends, not to the data. +``DIMDSEP`` is a one-character *string* here. DXF stores the character code +(group 278, a 16-bit value) and ezdxf raises on a string in the header, so the +translation belongs to the backends, not to the data — but the data is where +the refusal starts: a control character (``\\n`` splits the dimension text over +two lines, NUL reads back as a comma) or a code point above ``0x7FFF`` +(``ezdxf`` writes it, AutoCAD refuses it as "not a single character") is +refused here, before ``ord()`` runs in a backend. Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the tools and the provenance tests all read one copy. @@ -270,6 +274,15 @@ def ezdxf_arrowhead(canonical: str) -> str: return canonical +DIMDSEP_MAX_CODE = 0x7FFF +"""The largest character code ``DIMDSEP`` accepts. + +DXF group 278 is a 16-bit integer and the DWG field a signed short; AutoCAD +refuses anything above it as "not a single character", while ezdxf would +write it verbatim (measured: ``'\\U0001F600'`` reached the file as 128512). +""" + + def _number(key: str, value: Any) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError(f"{key}: expected a number, got {type(value).__name__}") @@ -324,6 +337,16 @@ def check_dim_value(name: str, value: Any) -> Any: raise TypeError(f"{key}: expected a string, got {type(value).__name__}") if len(value) != 1: # "char" raise ValueError(f"{key}: {value!r} must be exactly one character") + code = ord(value) + if code < 32 or code == 127: + raise ValueError( + f"{key}: {value!r} is a control character, which a DXF cannot hold as a separator" + ) + if code > DIMDSEP_MAX_CODE: + raise ValueError( + f"{key}: {value!r} (U+{code:04X}) is outside the 16-bit character code DXF group " + f"278 stores (at most U+{DIMDSEP_MAX_CODE:04X})" + ) return value diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index 61def1a..95dcd10 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -200,6 +200,35 @@ def test_unknown_preset_is_refused(): resolve_dimstyle("iso-25", [("DIMTXT", 2.5)]) +@pytest.mark.parametrize( + "separator", + ["\n", "\t", "\x00", "\x7f", chr(0x1F600), chr(0x8000), chr(0xFFFF)], + ids=["newline", "tab", "nul", "del", "non-bmp", "first-above-signed-short", "u-ffff"], +) +def test_dimdsep_refuses_what_a_dxf_character_code_cannot_hold(separator): + """DIMDSEP is the one variable stored as a character code (group 278, a + 16-bit value); ``len == 1`` alone let these through, and the headless + engine then wrote ``ord()`` unguarded — measured: ``'\\n'`` split the + dimension text over two lines, NUL read back as a comma, and the emoji + reached the file as 128512, which AutoCAD refuses as "not a single + character". ezdxf's audit reports none of it.""" + with pytest.raises(ValueError, match="DIMDSEP"): + check_dim_value("DIMDSEP", separator) + with pytest.raises(ValueError, match="DIMDSEP"): + resolve_dimstyle("iso-25", {"DIMDSEP": separator}) + + +@pytest.mark.parametrize( + "separator", + [",", ".", " ", chr(0xB7), chr(0x66B), chr(0x7FFF)], + ids=["comma", "point", "space", "middle-dot", "arabic-decimal", "last-signed-short"], +) +def test_dimdsep_takes_any_printable_separator_a_dxf_can_hold(separator): + """The comma, the point, the middle dot, the Arabic decimal separator, and + the last code a signed short holds all pass; the guard is not an ASCII gate.""" + assert check_dim_value("DIMDSEP", separator) == separator + + def test_validate_overrides_alone_is_what_modify_uses(): assert validate_overrides(None) == {} assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text" diff --git a/tests/test_styles.py b/tests/test_styles.py index 171e22e..cd73c70 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -134,6 +134,14 @@ async def test_refusals_happen_before_anything_is_written(backend): with pytest.raises(ValueError, match="does not exist"): await backend.dimstyle_set_current("NOPE") assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2 + # DIMDSEP is stored as ``ord(value)``: a control character or a code point + # above the 16-bit group 278 must stop here, not reach the table. + with pytest.raises(ValueError, match="DIMDSEP"): + await backend.dimstyle_create("Z", {"DIMDSEP": "\n"}) + assert not backend._doc.dimstyles.has_entry("Z") + with pytest.raises(ValueError, match="DIMDSEP"): + await backend.dimstyle_modify("ISO-25", {"DIMDSEP": "\U0001f600"}) + assert backend._doc.dimstyles.get("ISO-25").dxf.dimdsep == 44 async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path): From e204f227b4d72d1adb9d7e3e13b7c10e09aa5cb5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 16:48:20 +0300 Subject: [PATCH 07/13] fix(task-10): $DIMBLK header carries the canonical arrowhead name; MLeaderStyle is authored on COM through the ACAD_MLEADERSTYLE dictionary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both root causes. 1. `_sync_header_to_dimstyle` copied the DIMSTYLE entry's ezdxf-internal arrowhead spelling (OBLIQUE) into the DXF-facing $DIMBLK/$DIMBLK1/$DIMBLK2 header, so the saved file named a block it did not hold (the blocks are _OBLIQUE / _DOT) and `system_get_variable("DIMBLK")` answered "OBLIQUE" headlessly against "_OBLIQUE" on COM and in the same engine's own `dimstyle_list` row. The header write now goes through `reported_arrowhead` — the same read-side rule `_dimstyle_value` applies. Pinned by header, `system_get_variable`, save/reopen (`ezdxf.readfile` header equals the block the file holds) and the Restore-to-Standard empty-string case in `test_builtin_arrowheads_render_save_and_report_one_spelling`. 2. The `mleaderstyle` capability refusal rested on a false engine boundary. acax25enu.tlb (AutoCAD 2026) exposes IAcadMLeaderStyle with Name, ArrowSize, LandingGap, TextHeight, TextStyle, ... — exactly what `Dictionaries.Item("ACAD_MLEADERSTYLE").Item(i)` returns — and IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle") creates one. COM `mleaderstyle_list` now reads the four values off those objects (`values_available: True`); COM `mleaderstyle_create` validates every value (`validate_mleaderstyle`, shared with ezdxf), checks the clash and the text style before AddObject, then writes the four properties. The contract method is `@abstractmethod` again, the key is gone from both capability maps, the fake ActiveX surface models the real call shape, and the spec (§4.2, §8.1, §9) states the ActiveX route. Live AutoCAD was not running on this machine (GetActiveObject: operation unavailable; no acad.exe), so the COM route is type-library-verified and fake-tested, not executed live — scripts/smoke_settings_com.py (Task 24) owns that. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 94 ++++++++--- backends/contracts/styles.py | 35 ++-- backends/ezdxf_backend.py | 38 ++--- .../specs/2026-09-16-v1.6-settings-design.md | 13 +- engineering/standards/__init__.py | 7 +- engineering/standards/mleaderstyles.py | 32 +++- tests/test_standards_presets.py | 26 +++ tests/test_styles.py | 152 ++++++++++++++---- 8 files changed, 302 insertions(+), 95 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 3316d4c..e2f54d9 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -341,6 +341,39 @@ def _com_font_on_support_path(font_file: str) -> bool: return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder) +_MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE" +_MLEADERSTYLE_CLASS = "AcDbMLeaderStyle" + + +def _com_mleaderstyle_dictionary(doc, *, create: bool = False): + """The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none. + + ActiveX exposes no ``MLeaderStyles`` *collection*, but the dictionary's + items are full ``IAcadMLeaderStyle`` objects (acax25enu.tlb: ``Name``, + ``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and + ``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the + documented VBA route — creates one. Every drawing AutoCAD makes carries + the dictionary; ``create`` adds it to a foreign file that does not. The + route is verified against the AutoCAD 2026 type library and fake-tested; + its live execution is scripts/smoke_settings_com.py's job. + """ + try: + return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) + except Exception: + if not create: + return None + return doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY) + + +def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]: + """``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.""" + rows = [] + for index in range(dictionary.Count): + style = dictionary.Item(index) + rows.append((str(dictionary.GetName(style)), style)) + return rows + + def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]: """``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused.""" from engineering.standards.textstyles import TEXT_PRESETS @@ -923,13 +956,6 @@ 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" - ), - ), }, ) @@ -4420,21 +4446,19 @@ class ComBackend(AutoCADBackend): async def mleaderstyle_list(self) -> list[dict]: def _sync(): doc = _acad_doc() - try: - dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE") - except Exception: + dictionary = _com_mleaderstyle_dictionary(doc) + if dictionary is None: return [] rows = [] - for index in range(dictionary.Count): + for name, style in _com_mleaderstyle_rows(dictionary): 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, + "name": name, + "arrow_size": float(style.ArrowSize), + "landing_gap": float(style.LandingGap), + "text_style": str(style.TextStyle), + "text_height": float(style.TextHeight), + "values_available": True, } ) rows.sort(key=lambda row: row["name"].lower()) @@ -4442,6 +4466,40 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + async def mleaderstyle_create(self, name: str, values: dict) -> dict: + from engineering.standards.mleaderstyles import validate_mleaderstyle + + clean = sanitize_symbol_name(name, kind="mleaderstyle") + typed = validate_mleaderstyle(values) + + def _sync(): + doc = _acad_doc() + dictionary = _com_mleaderstyle_dictionary(doc, create=True) + wanted = clean.lower() + if any( + existing.lower() == wanted for existing, _ in _com_mleaderstyle_rows(dictionary) + ): + raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists") + # The text style must exist before TextStyle can name it — and a + # name that is neither present nor a preset is refused here, + # before AddObject, so a refusal leaves no half-made style behind. + text_style_name, textstyle_created = _ensure_com_textstyle( + doc, typed["text_style"], refusal_key="text_style" + ) + style = dictionary.AddObject(clean, _MLEADERSTYLE_CLASS) + style.ArrowSize = typed["arrow_size"] + style.LandingGap = typed["landing_gap"] + style.TextHeight = typed["text_height"] + style.TextStyle = text_style_name + return { + "ok": True, + "name": clean, + "values": {**typed, "text_style": text_style_name}, + "textstyle_created": textstyle_created, + } + + return await self._run(_sync) + # ── corner ops ────────────────────────────────────────────────────────── @staticmethod diff --git a/backends/contracts/styles.py b/backends/contracts/styles.py index f0bdbf7..04d0830 100644 --- a/backends/contracts/styles.py +++ b/backends/contracts/styles.py @@ -14,8 +14,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from backends.capability import capability - class StylesContract(ABC): @abstractmethod @@ -115,29 +113,24 @@ class StylesContract(ABC): 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``. + Both engines report the four values (``values_available: True``). + Headless they are read from the MLEADERSTYLE object (schema defaults + for an unset field); live from the ``AcadMLeaderStyle`` objects the + ``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap`` + / ``TextHeight`` in drawing units, ``TextStyle`` a name) — ActiveX + exposes no *collection* property for them, but the dictionary items + are full ``IAcadMLeaderStyle`` objects. """ ... - # 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)" - ), - ) + @abstractmethod async def mleaderstyle_create(self, name: str, values: dict) -> dict: """Create a multileader style from a resolved ``values`` dict (``engineering.standards.mleaderstyles.resolve_mleaderstyle``). - 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.""" + Both engines: ezdxf on ``doc.mleader_styles``; live through + ``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, + "AcDbMLeaderStyle")`` and the object's property writes. Returns + ``{ok, name, values, textstyle_created}``; the text style follows the + ``dimstyle_create`` rule (exists or preset, else refused by name); a + clash is refused — before anything is written.""" ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 7729517..1bd0796 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -830,8 +830,18 @@ def _sync_header_to_dimstyle(doc, style) -> None: `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` reports. + + The arrowheads cross a vocabulary boundary here. The DIMSTYLE entry holds + ezdxf's writer spelling (``OBLIQUE``, see `_write_dimstyle_values`); the + ``$DIMBLK`` / ``$DIMBLK1`` / ``$DIMBLK2`` header variables are DXF-facing + and spell a built-in with its underscore (``_OBLIQUE``, what a live seat + stores and what `system_get_variable("DIMBLK")` returns on COM). Copied + raw, the saved file carried ``$DIMBLK "OBLIQUE"`` next to blocks named + ``_OBLIQUE`` — a current-override naming a block that does not exist — + and the two engines answered the same read two ways. `reported_arrowhead` + is the same read-side rule `_dimstyle_value` applies. """ - from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST + from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST, reported_arrowhead for var in sorted(DIM_VARIABLE_WHITELIST): attr = var.lower() @@ -840,6 +850,8 @@ def _sync_header_to_dimstyle(doc, style) -> None: value = _dimstyle_schema_default(var) if value is None and var == "DIMTXSTY": value = "Standard" + if var in _ARROWHEAD_VARS: + value = reported_arrowhead(value) key = f"${var}" if value is None: try: @@ -1342,7 +1354,6 @@ class EzdxfBackend(AutoCADBackend): False, reason="acis_generation_requires_live_autocad" ), "lisp": FeatureCapability(False, reason="live_com_only"), - "mleaderstyle": FeatureCapability(True, "native"), }, ) @@ -6079,38 +6090,29 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def mleaderstyle_create(self, name: str, values: dict) -> dict: - from engineering.standards.mleaderstyles import MLEADER_KEYS + from engineering.standards.mleaderstyles import validate_mleaderstyle from security import sanitize_symbol_name 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" - ) + typed = validate_mleaderstyle(values) 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" + doc, typed["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.arrow_head_size = typed["arrow_size"] + style.dxf.landing_gap_size = typed["landing_gap"] + style.dxf.char_height = typed["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"]), - }, + "values": {**typed, "text_style": text_style_name}, "textstyle_created": textstyle_created, } diff --git a/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md b/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md index b5404bd..62b19ab 100644 --- a/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md +++ b/docs/superpowers/specs/2026-09-16-v1.6-settings-design.md @@ -118,7 +118,7 @@ name. - `dimstyle_modify(name, overrides)` — `changed` reports only variables that actually moved (the `hatch_edit` rule). Dimensions already using the style are listed in `dimensions_using_style` with `rerender_required: true` on the headless engine: AutoCAD re-renders them on the next regen, but a headless DIMENSION carries its rendered block, so the drawing shows the old style until the dimension is redrawn — stated rather than silent. - `dimstyle_set_current(name)` — `$DIMSTYLE`; the dimension tools then use it (the existing per-dimension override path stays as is). - `textstyle_list()`, `textstyle_create(name, font: "isocp.shx"|"isocpeur.ttf"|"arial.ttf"|"romans.shx"|, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)` — `$TEXTSTYLE`; a font file that is neither a bundled preset nor present in the engine's font path is written but reported `font_resolved: false` (AutoCAD substitutes at open; the headless renderer falls back), never refused, because DXF only stores the name. -- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height on ezdxf's `doc.mleader_styles`. ActiveX exposes no MLeaderStyle collection, so the COM engine refuses both with `capability: "mleaderstyle"` (the reason names `leader_create_mleader`'s existing per-leader parameters as the route); `mleaderstyle_list` on COM still reads the names from the `ACAD_MLEADERSTYLE` dictionary. +- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height, both engines: ezdxf on `doc.mleader_styles`; COM through the `ACAD_MLEADERSTYLE` dictionary, whose items are `IAcadMLeaderStyle` objects (`ArrowSize` / `LandingGap` / `TextHeight` in drawing units, `TextStyle` a name — verified against the AutoCAD 2026 type library), created with `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")` followed by the property writes. ActiveX has no `MLeaderStyles` *collection* property, which is not an engine boundary — no `mleaderstyle` capability key exists. - `drawing_apply_standard(standard: "iso"|"ansi", layers: bool = True, units: bool = True)` — one call: the dimstyle preset (created and set current), its text style, `drawing_settings` (units mm, decimal linear and angular units, LTSCALE 1, DIMSCALE 1; the decimal separator comes from the preset) and, with `layers`, the `mech` layer set for both standards (ANSI layer naming is company-specific; the set reuses the mech names with ISO 128 lineweights — only styles and the separator differ between the two). Returns, per item, whether it was created or was already present. ## 5. Settings (SECTION 20 shares these; 3 tools + facade growth) @@ -187,9 +187,10 @@ A state is the layer table snapshot `{layer: {on, frozen, locked, color, linetyp ### 8.1 Capability keys (added to **both** maps) -`mleaderstyle` (COM false), `dwgprops` (ezdxf false: summary fields), `dwt_write` -(ezdxf false), `live_application`, `preferences`, `interactive_prompt` (ezdxf -false). `tests/test_capability_contract.py` enforces both maps. +`dwgprops` (ezdxf false: summary fields), `dwt_write` (ezdxf false), +`live_application`, `preferences`, `interactive_prompt` (ezdxf false). +`tests/test_capability_contract.py` enforces both maps. (`mleaderstyle` was +listed here as COM false; the ActiveX route exists — §4.2 — so there is no key.) ### 8.2 Packs, profiles, discovery @@ -219,7 +220,9 @@ shop-floor synonyms; the discovery golden set gains ≥ 10 queries for them (A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog sysvar has type+meaning+engines. - Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call - shapes: `DimStyles.Add`, `TextStyles.Add`, `Layout.ConfigName/…`, + shapes: `DimStyles.Add`, `TextStyles.Add`, + `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")`, + `Layout.ConfigName/…`, `Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`, `UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`, `Utility.GetPoint`). diff --git a/engineering/standards/__init__.py b/engineering/standards/__init__.py index abb8477..115a25f 100644 --- a/engineering/standards/__init__.py +++ b/engineering/standards/__init__.py @@ -23,7 +23,11 @@ from engineering.standards.dimstyles import ( resolve_dimstyle, validate_overrides, ) -from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle +from engineering.standards.mleaderstyles import ( + MLEADER_PRESETS, + resolve_mleaderstyle, + validate_mleaderstyle, +) from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle __all__ = [ @@ -42,6 +46,7 @@ __all__ = [ "resolve_dimstyle", "resolve_font", "resolve_mleaderstyle", + "validate_mleaderstyle", "validate_overrides", "validate_textstyle", ] diff --git a/engineering/standards/mleaderstyles.py b/engineering/standards/mleaderstyles.py index 48a386d..e944aba 100644 --- a/engineering/standards/mleaderstyles.py +++ b/engineering/standards/mleaderstyles.py @@ -2,8 +2,10 @@ Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows and lettering; ANSI: 3 mm), so a leader note and a dimension on the same -sheet read at one size. Only ezdxf can author an MLEADERSTYLE object; -ActiveX exposes no collection for it (see ``backends/contracts/styles.py``). +sheet read at one size. Both engines author the style: ezdxf on +``doc.mleader_styles``, ActiveX through the ``ACAD_MLEADERSTYLE`` dictionary's +``AddObject(name, "AcDbMLeaderStyle")`` and the ``IAcadMLeaderStyle`` +properties (see ``backends/contracts/styles.py``). """ from __future__ import annotations @@ -13,7 +15,7 @@ from typing import Any from engineering.standards.names import check_name -__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"] +__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle", "validate_mleaderstyle"] MLEADER_PRESETS: dict[str, dict[str, Any]] = { "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, @@ -57,3 +59,27 @@ def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str else: values[name] = _positive(name, value) return values + + +def validate_mleaderstyle(values: Any) -> dict[str, Any]: + """The four ``MLEADER_KEYS`` of ``values``, typed, or a refusal naming the key. + + What both engines' ``mleaderstyle_create`` run before touching a table: + a missing key is ``ValueError`` (``resolve a preset first``), and each + value is checked exactly as :func:`resolve_mleaderstyle` checks an + override — so a hand-built dict is held to the preset's rules too. + Unknown keys are ignored: the row shape is the contract, not the input. + """ + if not isinstance(values, dict): + raise TypeError(f"values: expected a mapping, got {type(values).__name__}") + missing = [key for key in MLEADER_KEYS if key not in values] + if missing: + raise ValueError( + f"mleaderstyle_create: values is missing {missing}; resolve a preset first" + ) + return { + "arrow_size": _positive("arrow_size", values["arrow_size"]), + "landing_gap": _positive("landing_gap", values["landing_gap"], allow_zero=True), + "text_style": check_name("text_style", values["text_style"], what="text style name"), + "text_height": _positive("text_height", values["text_height"]), + } diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index 95dcd10..7c6d102 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -30,6 +30,7 @@ from engineering.standards.mleaderstyles import ( MLEADER_KEYS, MLEADER_PRESETS, resolve_mleaderstyle, + validate_mleaderstyle, ) from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle @@ -564,3 +565,28 @@ def test_resolve_mleaderstyle_applies_overrides(): def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment): with pytest.raises(exc, match=fragment): resolve_mleaderstyle(preset, overrides) + + +def test_validate_mleaderstyle_types_the_four_keys_and_refuses_by_name(): + """What both engines' ``mleaderstyle_create`` run before any write: a + hand-built dict is held to the same rules as a preset override.""" + assert validate_mleaderstyle(resolve_mleaderstyle("iso")) == MLEADER_PRESETS["iso"] + typed = validate_mleaderstyle( + {"arrow_size": 3, "landing_gap": 0, "text_style": " arial ", "text_height": 2, "extra": 1} + ) + assert typed == { + "arrow_size": 3.0, + "landing_gap": 0.0, + "text_style": "arial", + "text_height": 2.0, + } + with pytest.raises(ValueError, match=r"missing \['landing_gap', 'text_style', 'text_height'\]"): + validate_mleaderstyle({"arrow_size": 2.5}) + with pytest.raises(ValueError, match="arrow_size"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "arrow_size": 0}) + with pytest.raises(ValueError, match="text_style"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_style": "a|b"}) + with pytest.raises(TypeError, match="text_height"): + validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_height": "2.5"}) + with pytest.raises(TypeError, match="values"): + validate_mleaderstyle(["arrow_size"]) diff --git a/tests/test_styles.py b/tests/test_styles.py index cd73c70..8fd8c7c 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -16,7 +16,6 @@ import ezdxf import pytest import config -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 @@ -162,13 +161,31 @@ async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, t ) again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"}) assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)" + # The style is current, so Restore loaded it into the $DIM* header. The + # header is DXF-facing: a built-in is spelled with its underscore there + # (what a live seat stores and what COM's system_get_variable returns), + # never ezdxf's writer spelling the DIMSTYLE entry holds. + header = backend._doc.header + assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("_OBLIQUE", "_DOT", "") + assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE", "the same answer as COM" path = str(tmp_path / "arrows.dxf") await backend.drawing_save_as(path) + saved = ezdxf.readfile(path) + assert saved.header["$DIMBLK"] == "_OBLIQUE" and saved.header["$DIMBLK1"] == "_DOT", ( + "the file names the block it actually holds, not a block that does not exist" + ) + assert "_OBLIQUE" in saved.blocks and "_DOT" in saved.blocks await backend.drawing_open(path) rows = {r["name"]: r for r in await backend.dimstyle_list()} assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling" + assert await backend.system_get_variable("DIMBLK") == "_OBLIQUE" reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"}) assert reopened["changed"] == {} + await backend.dimstyle_set_current("Standard") + header = backend._doc.header # drawing_open replaced the document + assert (header["$DIMBLK"], header["$DIMBLK1"], header["$DIMBLK2"]) == ("", "", ""), ( + "closed filled is the empty string in the header, as in a fresh drawing" + ) async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend): @@ -434,12 +451,20 @@ async def test_mleaderstyle_refusals_write_nothing(backend): assert not backend._doc.mleader_styles.has_entry("X") -async def test_both_capability_maps_declare_mleaderstyle(backend): +async def test_mleaderstyle_is_not_a_capability_boundary_on_either_engine(backend): + """The v1.6 plan declared `mleaderstyle` COM-unsupported ("ActiveX exposes + no MLeaderStyle collection"). The type library says otherwise: the + ACAD_MLEADERSTYLE dictionary items are IAcadMLeaderStyle objects and + AddObject(name, "AcDbMLeaderStyle") creates one. A refusal resting on a + false premise is exactly what CLAUDE.md forbids `@capability` for, so + neither map declares the key and neither engine inherits a default.""" from backends.com_backend import ComBackend - 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"] + assert "mleaderstyle" not in backend.capabilities().to_dict()["features"] + assert "mleaderstyle" not in ComBackend().capabilities().to_dict()["features"] + for engine in (type(backend), ComBackend): + assert not is_capability_default(engine, "mleaderstyle_create") + assert not is_capability_default(engine, "mleaderstyle_list") # ── live engine, against a fake ActiveX surface ────────────────────────────── @@ -506,9 +531,32 @@ class _FakeLayouts: return self.layouts[index] +class _FakeMLeaderStyle: + """An AcadMLeaderStyle: the properties acax25enu.tlb's IAcadMLeaderStyle + exposes that the backend reads or writes (doubles in drawing units, the + text style by name), with AutoCAD's defaults for a fresh style.""" + + PROPERTIES = ("ArrowSize", "LandingGap", "TextHeight", "TextStyle") + + def __init__(self, name, calls): + self.Name = name + self.ObjectName = "AcDbMLeaderStyle" + self.ArrowSize, self.LandingGap, self.TextHeight, self.TextStyle = 4.0, 2.0, 4.0, "Standard" + self._calls = calls # last: property writes are recorded from here on + + def __setattr__(self, key, value): + if key in self.PROPERTIES and "_calls" in vars(self): + self._calls.append(("MLeaderStyle." + key, self.Name, value)) + super().__setattr__(key, value) + + class _FakeDictionary: - def __init__(self, *names): - self.objects = [types.SimpleNamespace(mleader_name=name) for name in names] + """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects + and AddObject(keyword, class_name), the documented VBA route.""" + + def __init__(self, calls, *names): + self._calls = calls + self.objects = [_FakeMLeaderStyle(name, calls) for name in names] @property def Count(self): @@ -518,7 +566,15 @@ class _FakeDictionary: return self.objects[index] def GetName(self, obj): - return obj.mleader_name + return obj.Name + + def AddObject(self, keyword, class_name): + self._calls.append(("Dictionary.AddObject", keyword, class_name)) + if class_name != "AcDbMLeaderStyle": + raise RuntimeError(f"cannot create {class_name} here") + style = _FakeMLeaderStyle(keyword, self._calls) + self.objects.append(style) + return style class _FakeDictionaries: @@ -570,7 +626,7 @@ class _FakeDocument: ) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) self.Dictionaries = _FakeDictionaries( - {"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")} + {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")} ) def GetVariable(self, name): @@ -751,36 +807,74 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend): assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True} -async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend): +async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend): backend, document = com_backend + dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary.objects[1].ArrowSize = 3.0 + dictionary.objects[1].TextStyle = "ISOCP" + document.calls.clear() # fixture setup, not backend calls rows = await backend.mleaderstyle_list() assert rows == [ { "name": "Annotative", - "arrow_size": None, - "landing_gap": None, - "text_style": None, - "text_height": None, - "values_available": False, + "arrow_size": 3.0, + "landing_gap": 2.0, + "text_style": "ISOCP", + "text_height": 4.0, + "values_available": True, }, { "name": "Standard", - "arrow_size": None, - "landing_gap": None, - "text_style": None, - "text_height": None, - "values_available": False, + "arrow_size": 4.0, + "landing_gap": 2.0, + "text_style": "Standard", + "text_height": 4.0, + "values_available": True, }, ] + assert document.calls == [], "a list reads; it writes nothing" -async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend): - from backends.com_backend import ComBackend - +async def test_com_mleaderstyle_create_adds_the_object_then_writes_its_properties(com_backend): 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 == [] + result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + assert result == { + "ok": True, + "name": "ISO", + "values": { + "arrow_size": 2.5, + "landing_gap": 1.0, + "text_style": "ISOCP", + "text_height": 2.5, + }, + "textstyle_created": True, + } + names = [call[0] for call in document.calls] + assert names.index("TextStyles.Add") < names.index("Dictionary.AddObject"), ( + "the text style exists before TextStyle names it" + ) + assert ("Dictionary.AddObject", "ISO", "AcDbMLeaderStyle") in document.calls + writes = [call for call in document.calls if call[0].startswith("MLeaderStyle.")] + assert writes == [ + ("MLeaderStyle.ArrowSize", "ISO", 2.5), + ("MLeaderStyle.LandingGap", "ISO", 1.0), + ("MLeaderStyle.TextHeight", "ISO", 2.5), + ("MLeaderStyle.TextStyle", "ISO", "ISOCP"), + ] + rows = {row["name"]: row for row in await backend.mleaderstyle_list()} + assert rows["ISO"] == {**result["values"], "name": "ISO", "values_available": True} + + +async def test_com_mleaderstyle_refusals_write_nothing(com_backend): + backend, document = com_backend + dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + with pytest.raises(ValueError, match="already exists"): + await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None)) + with pytest.raises(ValueError, match="text_style"): + await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"})) + with pytest.raises(ValueError, match="missing"): + await backend.mleaderstyle_create("X", {"arrow_size": 2.5}) + with pytest.raises(ValueError, match="arrow_size"): + await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0}) + assert document.calls == [], "nothing reached ActiveX" + assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"] From 266b6fb838b32ab3e9beb3221382aa4771fd8f5e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:54:43 +0300 Subject: [PATCH 08/13] fix(task-10): un-narrow the ACAD_MLEADERSTYLE dictionary route so mleaderstyle_list/create work on the live engine acax25enu.tlb declares IAcadDictionaries.Item, IAcadDictionary.Item and IAcadDictionary.AddObject as returning IAcadObject*. With a makepy cache for the AutoCAD type library present (it is, on any machine where gencache ever ran against AutoCAD), pywin32 wraps each result in the gen_py class of the declared type, which exposes only IAcadObject members: .Count/.Item/.GetName on the dictionary and .ArrowSize/.LandingGap/.TextHeight/.TextStyle on a style all raised AttributeError, so the previous round's removal of the @capability refusal turned a typed UnsupportedCapabilityError into a raw AttributeError on AutoCAD 2026. `_com_unnarrow` re-dispatches an object on its raw IDispatch through win32com.client.dynamic.Dispatch (no gencache side effect, unlike CastTo), and the dictionary route applies it to the dictionary, to every Item(i) and to the AddObject result. Verified live on AutoCAD 2026 (scratch document, closed afterwards): list reads Standard = 0.18/0.09/0.18/Standard, create writes ISO-LDR = 2.5/1.0/2.5/ISOCP, the duplicate is refused, and an independent late-bound read of the dictionary agrees. The fake in tests/test_styles.py now models the narrowing: Dictionaries.Item, Dictionary.Item and Dictionary.AddObject hand back a wrapper that answers only IAcadObject members and raises on everything else (reads and writes alike) until unwrapped through _oleobj_. Five COM tests fail against the previous backend and pass now; a new test pins the missing-dictionary path (Dictionaries.Add before AddObject). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 57 +++++++++++++--- tests/test_styles.py | 145 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 186 insertions(+), 16 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index e2f54d9..76fe4bc 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -345,6 +345,37 @@ _MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE" _MLEADERSTYLE_CLASS = "AcDbMLeaderStyle" +def _com_unnarrow(obj): + """Re-dispatch ``obj`` on its raw ``IDispatch`` so its *runtime* class is reachable. + + acax25enu.tlb declares ``IAcadDictionaries.Item``, ``IAcadDictionary.Item`` + and ``IAcadDictionary.AddObject`` as returning ``IAcadObject*``. Whenever a + makepy cache for the AutoCAD type library exists (it does on any machine + where ``win32com.client.gencache`` ever ran against AutoCAD), pywin32 wraps + such a return in the gen_py class of the *declared* type, and that wrapper + exposes only ``IAcadObject`` members: ``.Count`` / ``.Item`` / ``.GetName`` + on the dictionary and ``.ArrowSize`` / ``.TextStyle`` on a leader style all + raise ``AttributeError`` even though the live object answers them. (The + rest of this backend never meets the problem: ``HandleToObject`` is declared + ``IDispatch*``, which keeps the concrete class, and ``ModelSpace.Item`` is + read only through ``IAcadEntity`` members.) + + ``win32com.client.dynamic.Dispatch`` on the raw interface builds a + late-bound proxy from the object's own type info, so every member of the + concrete class resolves — verified live on AutoCAD 2026 — and, unlike + ``CastTo``, it never calls ``gencache.EnsureDispatch`` and never writes the + makepy cache. A wrapper whose ``_oleobj_`` is not a COM interface (the + fake in tests/test_styles.py) is unwrapped to that object as-is; anything + without ``_oleobj_`` is returned unchanged. + """ + ole = getattr(obj, "_oleobj_", None) + if ole is None: + return obj + if _COM_IMPORTS_OK and isinstance(ole, pythoncom.TypeIIDs[pythoncom.IID_IDispatch]): + return win32com.client.dynamic.Dispatch(ole) + return ole + + def _com_mleaderstyle_dictionary(doc, *, create: bool = False): """The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none. @@ -353,23 +384,31 @@ def _com_mleaderstyle_dictionary(doc, *, create: bool = False): ``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and ``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the documented VBA route — creates one. Every drawing AutoCAD makes carries - the dictionary; ``create`` adds it to a foreign file that does not. The - route is verified against the AutoCAD 2026 type library and fake-tested; - its live execution is scripts/smoke_settings_com.py's job. + the dictionary; ``create`` adds it to a foreign file that does not. + + Every object this route hands over goes through ``_com_unnarrow`` (see + there): ``Dictionaries.Item`` is declared ``IAcadObject*`` and comes back + as a wrapper with no ``Count``. Verified live on AutoCAD 2026 (list and + create on a scratch document); scripts/smoke_settings_com.py re-runs it. """ try: - return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) + dictionary = doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) except Exception: if not create: return None - return doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY) + dictionary = doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY) + return _com_unnarrow(dictionary) def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]: - """``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.""" + """``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key. + + Each ``Item(i)`` is un-narrowed: declared ``IAcadObject*``, it would + otherwise carry no ``ArrowSize``. + """ rows = [] for index in range(dictionary.Count): - style = dictionary.Item(index) + style = _com_unnarrow(dictionary.Item(index)) rows.append((str(dictionary.GetName(style)), style)) return rows @@ -4486,7 +4525,9 @@ class ComBackend(AutoCADBackend): text_style_name, textstyle_created = _ensure_com_textstyle( doc, typed["text_style"], refusal_key="text_style" ) - style = dictionary.AddObject(clean, _MLEADERSTYLE_CLASS) + # AddObject is declared IAcadObject* too: un-narrowed, or the + # first property write below raises AttributeError. + style = _com_unnarrow(dictionary.AddObject(clean, _MLEADERSTYLE_CLASS)) style.ArrowSize = typed["arrow_size"] style.LandingGap = typed["landing_gap"] style.TextHeight = typed["text_height"] diff --git a/tests/test_styles.py b/tests/test_styles.py index 8fd8c7c..82594ba 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -550,12 +550,64 @@ class _FakeMLeaderStyle: super().__setattr__(key, value) +class _NarrowedToIAcadObject: + """What pywin32 hands back for ``Dictionaries.Item``, ``Dictionary.Item`` + and ``Dictionary.AddObject`` once a makepy cache for the AutoCAD type + library exists: acax25enu.tlb declares all three as returning + ``IAcadObject*``, so the result is the gen_py ``IAcadObject`` class, which + exposes only IAcadObject members and raises AttributeError for everything + the runtime class (``IAcadDictionary``, ``IAcadMLeaderStyle``) adds — on a + read *and* on a property write. The full object is reachable only through + ``_oleobj_``, which is how the backend must un-narrow it (this is exactly + what happened live on AutoCAD 2026 before the fix).""" + + IACADOBJECT_MEMBERS = frozenset( + { + "Application", + "Database", + "Document", + "Handle", + "HasExtensionDictionary", + "ObjectID", + "ObjectName", + "OwnerID", + "Delete", + "Erase", + "GetExtensionDictionary", + "GetXData", + "SetXData", + } + ) + + def __init__(self, raw): + object.__setattr__(self, "_oleobj_", raw) + + def __getattr__(self, key): + if key in self.IACADOBJECT_MEMBERS: + return getattr(self._oleobj_, key) + raise AttributeError( + "'' " + f"object has no attribute {key!r}" + ) + + def __setattr__(self, key, value): + # gen_py's DispatchBaseClass.__setattr__ consults _prop_map_put_, and + # IAcadObject's is empty: every property write raises. + raise AttributeError( + "'' " + f"object has no attribute {key!r}" + ) + + class _FakeDictionary: """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects - and AddObject(keyword, class_name), the documented VBA route.""" + and AddObject(keyword, class_name), the documented VBA route. Item and + AddObject hand their result over narrowed to IAcadObject, as pywin32 + does.""" def __init__(self, calls, *names): self._calls = calls + self.ObjectName = "AcDbDictionary" self.objects = [_FakeMLeaderStyle(name, calls) for name in names] @property @@ -563,10 +615,14 @@ class _FakeDictionary: return len(self.objects) def Item(self, index): - return self.objects[index] + return _NarrowedToIAcadObject(self.objects[index]) def GetName(self, obj): - return obj.Name + # ActiveX takes the object itself; a narrowed wrapper resolves to the + # same object, so both spellings name it. + raw = getattr(obj, "_oleobj_", obj) + assert raw in self.objects, "GetName: object is not in this dictionary" + return raw.Name def AddObject(self, keyword, class_name): self._calls.append(("Dictionary.AddObject", keyword, class_name)) @@ -574,16 +630,27 @@ class _FakeDictionary: raise RuntimeError(f"cannot create {class_name} here") style = _FakeMLeaderStyle(keyword, self._calls) self.objects.append(style) - return style + return _NarrowedToIAcadObject(style) class _FakeDictionaries: - def __init__(self, entries): + """``doc.Dictionaries``: Item is declared IAcadObject* (narrowed); Add is + declared IAcadDictionary* and comes back with its full surface.""" + + def __init__(self, calls, entries): + self._calls = calls self.entries = entries def Item(self, name): if name not in self.entries: raise RuntimeError(f"no dictionary {name}") + return _NarrowedToIAcadObject(self.entries[name]) + + def Add(self, name): + self._calls.append(("Dictionaries.Add", name)) + if name in self.entries: + raise RuntimeError(f"dictionary {name} already exists") + self.entries[name] = _FakeDictionary(self._calls) return self.entries[name] @@ -626,9 +693,16 @@ class _FakeDocument: ) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) self.Dictionaries = _FakeDictionaries( - {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")} + self.calls, + {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")}, ) + @property + def mleader_dictionary(self) -> _FakeDictionary: + """The raw ACAD_MLEADERSTYLE dictionary, for test setup and assertions + (the backend only ever sees it narrowed through ``Dictionaries.Item``).""" + return self.Dictionaries.entries["ACAD_MLEADERSTYLE"] + def GetVariable(self, name): return self.variables[name] @@ -809,7 +883,7 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend): async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend): backend, document = com_backend - dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary = document.mleader_dictionary dictionary.objects[1].ArrowSize = 3.0 dictionary.objects[1].TextStyle = "ISOCP" document.calls.clear() # fixture setup, not backend calls @@ -867,7 +941,7 @@ async def test_com_mleaderstyle_create_adds_the_object_then_writes_its_propertie async def test_com_mleaderstyle_refusals_write_nothing(com_backend): backend, document = com_backend - dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary = document.mleader_dictionary with pytest.raises(ValueError, match="already exists"): await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None)) with pytest.raises(ValueError, match="text_style"): @@ -878,3 +952,58 @@ async def test_com_mleaderstyle_refusals_write_nothing(com_backend): await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0}) assert document.calls == [], "nothing reached ActiveX" assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"] + + +async def test_com_dictionary_route_is_narrowed_to_iacadobject_until_unwrapped(com_backend): + """The failure mode as it happened live (AutoCAD 2026, pywin32 with a + makepy cache): every object the dictionary route returns is wrapped as + the DECLARED type, IAcadObject, so Count / Item / GetName on the + dictionary and ArrowSize on a style raise AttributeError. The fake + models that narrowing, and `_com_unnarrow` is the one door through it — + a raw non-COM `_oleobj_` is handed back as-is, an object without one is + returned unchanged.""" + from backends.com_backend import _com_unnarrow + + backend, document = com_backend + narrowed = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + assert narrowed.ObjectName == "AcDbDictionary", "IAcadObject members still answer" + with pytest.raises(AttributeError, match="no attribute 'Count'"): + getattr(narrowed, "Count") # noqa: B009 — the read itself is what raises + with pytest.raises(AttributeError, match="no attribute 'Item'"): + narrowed.Item(0) + raw = document.mleader_dictionary + assert _com_unnarrow(narrowed) is raw + assert _com_unnarrow(raw) is raw, "an object without _oleobj_ is returned unchanged" + + item = raw.Item(0) + assert item.ObjectName == "AcDbMLeaderStyle" + with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"): + getattr(item, "ArrowSize") # noqa: B009 + with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"): + item.ArrowSize = 2.5 + assert _com_unnarrow(item) is raw.objects[0] + + added = raw.AddObject("LDR-A", "AcDbMLeaderStyle") + with pytest.raises(AttributeError, match="no attribute 'TextStyle'"): + added.TextStyle = "ISOCP" + assert _com_unnarrow(added) is raw.objects[-1] + assert raw.GetName(added) == raw.GetName(raw.objects[-1]) == "LDR-A" + + +async def test_com_mleaderstyle_create_adds_the_dictionary_to_a_drawing_without_one(com_backend): + """A foreign drawing may lack ACAD_MLEADERSTYLE: list reports no styles + (and never adds the dictionary), create adds it through Dictionaries.Add + — whose IAcadDictionary* result is un-narrowed like the rest — and then + the style.""" + backend, document = com_backend + del document.Dictionaries.entries["ACAD_MLEADERSTYLE"] + assert await backend.mleaderstyle_list() == [] + assert document.calls == [] and "ACAD_MLEADERSTYLE" not in document.Dictionaries.entries + result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None)) + assert result["ok"] is True and result["name"] == "ISO" + names = [call[0] for call in document.calls] + assert names.index("Dictionaries.Add") < names.index("Dictionary.AddObject") + assert ("Dictionaries.Add", "ACAD_MLEADERSTYLE") in document.calls + assert [obj.Name for obj in document.mleader_dictionary.objects] == ["ISO"] + rows = await backend.mleaderstyle_list() + assert [row["name"] for row in rows] == ["ISO"] and rows[0]["arrow_size"] == 2.5 From a60f801205e8296d527a30c994d23486b8fb8d23 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:44:02 +0300 Subject: [PATCH 09/13] =?UTF-8?q?feat(server):=20SECTION=2018=20styles=20?= =?UTF-8?q?=E2=80=94=20dimstyle/textstyle/mleaderstyle=20tools=20with=20DI?= =?UTF-8?q?MSTYLE/STYLE/MLEADERSTYLE=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- discovery/aliases.py | 90 +++++++++++++ docs/tool-inventory.json | 131 ++++++++++++++++++- server.py | 249 ++++++++++++++++++++++++++++++++++++ tests/test_styles.py | 80 ++++++++++++ tests/test_tool_registry.py | 11 +- 6 files changed, 557 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0864612..2779664 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 175 tools · 8 resources · 5 prompt templates · 2423 collected tests. > 166 is the **registered** count; a default install advertises 161 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..c560d82 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -72,20 +72,27 @@ class ToolAliases: # PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run # between two ports (pid_line_draw), which is an LWPOLYLINE with a # class, a number and markers; a drafter typing it could mean either. +# DIMSTYLE, STYLE, MLEADERSTYLE -- one dialog each in AutoCAD for +# list / create / modify / set-current; this server splits each into +# discrete style tools (track E), so the command is a correct +# destination for every one of them. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", "BLOCK", + "DIMSTYLE", "ERASE", "INSERT", "LAYER", "LAYOUT", "MEASUREGEOM", + "MLEADERSTYLE", "PLINE", "QSELECT", "SETVAR", + "STYLE", "ZOOM", } ) @@ -1427,6 +1434,89 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "loop number", ), ), + # ── Styles (track E, group S) ─────────────────────────────────────────── + "dimstyle_create": ToolAliases( + acad=("DIMSTYLE", "DDIM"), + synonyms=( + "new dimension style", + "create a dimension style", + "iso-25 dimension style", + "ansi dimension style", + "dimension style from a preset", + "set up dimension text height and arrows", + ), + ), + "dimstyle_list": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "list dimension styles", + "dimension styles in the drawing", + "dimension style table", + "current dimstyle", + "dim styles", + ), + ), + "dimstyle_modify": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "change a dimension style", + "edit the dimension style", + "change dimension text height for the whole drawing", + "set dimdec on a style", + "modify dimstyle variables", + ), + ), + "dimstyle_set_current": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "current dimension style", + "make this dimension style current", + "switch dimension style", + "use iso-25 for new dimensions", + "activate a dimstyle", + ), + ), + "mleaderstyle_create": ToolAliases( + acad=("MLEADERSTYLE",), + synonyms=( + "new leader style", + "multileader style", + "leader arrow size and landing", + "iso leader style", + ), + ), + "mleaderstyle_list": ToolAliases( + acad=("MLEADERSTYLE",), + synonyms=("list leader styles", "multileader styles in the drawing", "mleader styles"), + ), + "textstyle_create": ToolAliases( + acad=("STYLE",), + synonyms=( + "new text style", + "create a text style", + "isocp font", + "set the font", + "text style with width factor and oblique angle", + ), + ), + "textstyle_list": ToolAliases( + acad=("STYLE",), + synonyms=( + "list text styles", + "fonts loaded in the drawing", + "text style table", + "current text style", + ), + ), + "textstyle_set_current": ToolAliases( + acad=("STYLE", "TEXTSTYLE"), + synonyms=( + "make this text style current", + "switch the text style", + "use isocp for new text", + "active text style", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..8543e5b 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,10 +3,10 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 166, + "tools": 175, "resources": 8, "prompts": 5, - "groups": 20 + "groups": 21 }, "groups": { "analysis": 12, @@ -24,6 +24,7 @@ "pid": 9, "premium": 12, "solids": 5, + "styles": 9, "system": 7, "templates": 2, "transactions": 3, @@ -450,6 +451,62 @@ "readOnlyHint": false } }, + { + "name": "dimstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Dimension Style", + "readOnlyHint": false + } + }, + { + "name": "dimstyle_list", + "group": "styles", + "cost": "read", + "summary": "List dimension styles with their ISO-25/ANSI variables and the current one.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Dimension Styles", + "readOnlyHint": true + } + }, + { + "name": "dimstyle_modify", + "group": "styles", + "cost": "mutate", + "summary": "Change DIM* variables on an existing dimension style; reports only what moved.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Modify Dimension Style", + "readOnlyHint": false + } + }, + { + "name": "dimstyle_set_current", + "group": "styles", + "cost": "safe", + "summary": "Make a dimension style current so new dimensions use it.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Set Current Dimension Style", + "readOnlyHint": false + } + }, { "name": "drawing_apply_iso_layers", "group": "premium", @@ -1717,6 +1774,34 @@ "readOnlyHint": false } }, + { + "name": "mleaderstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a multileader style from the ISO or ANSI preset, on both engines.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Multileader Style", + "readOnlyHint": false + } + }, + { + "name": "mleaderstyle_list", + "group": "styles", + "cost": "read", + "summary": "List multileader styles: arrow size, landing gap, text style and height.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Multileader Styles", + "readOnlyHint": true + } + }, { "name": "pid_equipment_list", "group": "pid", @@ -2155,6 +2240,48 @@ "destructiveHint": false } }, + { + "name": "textstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Text Style", + "readOnlyHint": false + } + }, + { + "name": "textstyle_list", + "group": "styles", + "cost": "read", + "summary": "List text styles: font file, height, width factor, oblique angle.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Text Styles", + "readOnlyHint": true + } + }, + { + "name": "textstyle_set_current", + "group": "styles", + "cost": "safe", + "summary": "Make a text style current so new text uses it.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Set Current Text Style", + "readOnlyHint": false + } + }, { "name": "titleblock_apply_iso_a3", "group": "engineering", diff --git a/server.py b/server.py index 27a06b2..9e22520 100644 --- a/server.py +++ b/server.py @@ -703,6 +703,7 @@ _GROUP_TAG_PRIORITY = ( "validation", "analysis", "block", + "style", "layer", "linetype", "create", @@ -728,6 +729,7 @@ _GROUP_TAG_LABELS = { "analysis": "analysis", "validation": "validation", "block": "blocks", + "style": "styles", "layer": "layers", "linetype": "layers", "create": "entity_creation", @@ -6815,6 +6817,253 @@ async def pid_tag_parse( return parse_tag(tag, kind, equipment_prefixes) +# --------------------------------------------------------------------------- +# ── SECTION 18: Styles (9 tools) ──────────────────────────────────────────── +# --------------------------------------------------------------------------- +# +# Pack: core (drafting essentials). Lean profile: dimstyle_set_current and +# textstyle_set_current only (Task 13) — a lean client needs to *use* a +# style a template already holds far more often than to author one. + + +@cad_tool( + summary="List dimension styles with their ISO-25/ANSI variables and the current one.", + cost="read", +) +@mcp.tool( + annotations={"title": "Styles: List Dimension Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def dimstyle_list(ctx: Context = None) -> dict: + """Every DIMSTYLE table entry with the seventeen preset variables + (DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH, DIMDEC, + DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE). + + Headless, `values` is what the DXF stores (the schema default where an + entry omits a code). On the live engine ActiveX has no per-style getters, + so `values` is read for the *current* style only and the other rows carry + `values: null`, `values_available: false` — `dimstyle_set_current` a + style to read it. Never refuses on an open drawing. + """ + rows = await _backend(ctx).dimstyle_list() + return { + "ok": True, + "styles": rows, + "count": len(rows), + "current": next((row["name"] for row in rows if row["current"]), None), + } + + +@cad_tool( + summary="Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Dimension Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def dimstyle_create( + name: Annotated[str, "New dimension style name, e.g. ISO-25, ANSI, MECH-A3"], + preset: Annotated[ + str | None, + "iso-25 (ISO 129-1, 2.5 mm, comma) | ansi (ASME Y14.2, 3 mm, point); omit for the ISO-25 base", + ] = None, + overrides: Annotated[ + dict | None, + "DIM* variables applied on top of the preset, e.g. {DIMTXT: 3.5, DIMDSEP: '.', DIMTAD: 0}", + ] = None, + set_current: Annotated[bool, "Also make it the current dimension style"] = False, + ctx: Context = None, +) -> dict: + """Create a dimension style: preset values first, then `overrides`. + + Refuses, before anything is written: an unknown preset; an override key + outside the 33-variable whitelist (the 17 preset variables plus DIMTOL, + DIMTP, DIMTM, DIMTOLJ, DIMTFAC, DIMLFAC, DIMRND, DIMATFIT, DIMTMOVE, + DIMCLRD/E/T, DIMSAH, DIMBLK1/2, DIMCEN); a value outside its range + (DIMDEC 0-8, sizes > 0, DIMTAD 0-4, DIMDSEP one character, lineweights an + AutoCAD code); a name that already exists (use `dimstyle_modify`); a + DIMTXSTY that is neither an existing text style nor a bundled preset + (ISOCP, ISOCPEUR, ARIAL, ROMANS — those are created, `textstyle_created`). + With `set_current` the next dimension carries the style on both engines. + """ + from engineering.standards.dimstyles import resolve_dimstyle + + values = resolve_dimstyle(preset, overrides) + await ctx.info(f"Dimension style {name!r} from preset {preset or 'iso-25'}") + return await _backend(ctx).dimstyle_create(name, values, set_current) + + +@cad_tool( + summary="Change DIM* variables on an existing dimension style; reports only what moved.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Modify Dimension Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def dimstyle_modify( + name: Annotated[str, "Existing dimension style name"], + overrides: Annotated[dict, "DIM* variables to change, e.g. {DIMDEC: 3, DIMTXT: 3.5}"], + ctx: Context = None, +) -> dict: + """Change variables on an existing dimension style. + + `changed` names only the variables that actually moved — re-setting a + value to itself is not a change. `dimensions_using_style` lists the + dimensions already drawn with it; headlessly `rerender_required: true` + says they still show the old style until redrawn (AutoCAD re-renders on + the next regen). Refuses a missing style, an empty `overrides`, a key + outside the whitelist, a value outside its range, a DIMTXSTY that does + not exist — all before any write. On the live engine, editing a + non-current style makes it current for the duration of the call and the + previous style is restored (its unsaved overrides are discarded, which is + AutoCAD's own DIMSTYLE rule). + """ + await ctx.info(f"Modifying dimension style {name!r}") + return await _backend(ctx).dimstyle_modify(name, overrides) + + +@cad_tool(summary="Make a dimension style current so new dimensions use it.", cost="safe") +@mcp.tool( + annotations={"title": "Styles: Set Current Dimension Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def dimstyle_set_current( + name: Annotated[str, "Dimension style to make current (case-insensitive)"], + ctx: Context = None, +) -> dict: + """Set `$DIMSTYLE` and load the style's variables as the current DIM* + settings — what a live seat's DIMSTYLE Restore does — so `dimension_*` + and `dimension_auto` draw with it on both engines. Per-dimension + tolerances, fits and text overrides still apply on top. Refuses a style + the drawing does not hold. Returns `previous` and `changed`. + """ + return await _backend(ctx).dimstyle_set_current(name) + + +@cad_tool(summary="List text styles: font file, height, width factor, oblique angle.", cost="read") +@mcp.tool( + annotations={"title": "Styles: List Text Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def textstyle_list(ctx: Context = None) -> dict: + """Every STYLE table entry and the one new TEXT/MTEXT will use.""" + rows = await _backend(ctx).textstyle_list() + return { + "ok": True, + "styles": rows, + "count": len(rows), + "current": next((row["name"] for row in rows if row["current"]), None), + } + + +@cad_tool( + summary="Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Text Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def textstyle_create( + name: Annotated[str, "New text style name, e.g. ISOCP"], + font: Annotated[ + str, + "isocp.shx | isocpeur.ttf | arial.ttf | romans.shx (name or file, any case) or any font file", + ], + height: Annotated[ + float, Field(default=0.0, ge=0, description="Fixed height; 0 = ask per text") + ] = 0.0, + width_factor: Annotated[float, Field(default=1.0, gt=0, description="Width factor")] = 1.0, + oblique_deg: Annotated[float, "Obliquing angle in degrees, -85..85"] = 0.0, + set_current: Annotated[bool, "Also make it the current text style"] = False, + ctx: Context = None, +) -> dict: + """Create a text style. + + A font that is neither a bundled preset nor on the engine's font path is + *written* and reported `font_resolved: false` (AutoCAD substitutes at + open; the headless renderer falls back) — never refused, because a DXF + stores only the name. Refuses, before any write: a name that exists, an + empty font, `width_factor <= 0`, `height < 0`, an oblique angle outside + ±85°. + """ + await ctx.info(f"Text style {name!r} with font {font!r}") + return await _backend(ctx).textstyle_create( + name, font, height, width_factor, oblique_deg, set_current + ) + + +@cad_tool(summary="Make a text style current so new text uses it.", cost="safe") +@mcp.tool( + annotations={"title": "Styles: Set Current Text Style", "readOnlyHint": False}, + tags={"style", "modify"}, +) +async def textstyle_set_current( + name: Annotated[str, "Text style to make current (case-insensitive)"], + ctx: Context = None, +) -> dict: + """Set `$TEXTSTYLE`; `entity_create_text` / `entity_create_mtext` then + carry the style on both engines. Refuses a style the drawing does not + hold. Returns `previous` and `changed`.""" + return await _backend(ctx).textstyle_set_current(name) + + +@cad_tool( + summary="List multileader styles: arrow size, landing gap, text style and height.", + cost="read", +) +@mcp.tool( + annotations={"title": "Styles: List Multileader Styles", "readOnlyHint": True}, + tags={"style", "query"}, +) +async def mleaderstyle_list(ctx: Context = None) -> dict: + """Every MLEADERSTYLE with its four values on both engines + (`values_available: true`). Headless they are read from the MLEADERSTYLE + object; live from the `AcadMLeaderStyle` objects the `ACAD_MLEADERSTYLE` + dictionary holds (ActiveX exposes no *collection* property for them, but + the dictionary items are full objects). `leader_create_mleader` can still + override any of them per leader.""" + rows = await _backend(ctx).mleaderstyle_list() + return {"ok": True, "styles": rows, "count": len(rows)} + + +@cad_tool( + summary="Create a multileader style from the ISO or ANSI preset, on both engines.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Styles: Create Multileader Style", "readOnlyHint": False}, + tags={"style", "create"}, +) +async def mleaderstyle_create( + name: Annotated[str, "New multileader style name"], + preset: Annotated[ + str, "iso (2.5 mm arrow, 1.0 landing, ISOCP 2.5) | ansi (3.0, 1.5, ROMANS 3.0)" + ], + overrides: Annotated[ + dict | None, "arrow_size | landing_gap | text_style | text_height on top of the preset" + ] = None, + ctx: Context = None, +) -> dict: + """Create a multileader style: preset values first, then `overrides`. + + Headless it lands on `doc.mleader_styles`; live it is added through the + `ACAD_MLEADERSTYLE` dictionary (`AddObject(name, "AcDbMLeaderStyle")`) + and the object's properties are written. Refuses, before any write: an + unknown preset; an override key outside the four; `arrow_size` / + `text_height <= 0`, `landing_gap < 0`; a name that exists; a `text_style` + that is neither present nor a bundled preset (ISOCP, ISOCPEUR, ARIAL, + ROMANS — those are created, `textstyle_created`). + """ + from engineering.standards.mleaderstyles import resolve_mleaderstyle + + values = resolve_mleaderstyle(preset, overrides) + await ctx.info(f"Multileader style {name!r} from preset {preset}") + return await _backend(ctx).mleaderstyle_create(name, values) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_styles.py b/tests/test_styles.py index 82594ba..7bb8eaa 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -1007,3 +1007,83 @@ async def test_com_mleaderstyle_create_adds_the_dictionary_to_a_drawing_without_ assert [obj.Name for obj in document.mleader_dictionary.objects] == ["ISO"] rows = await backend.mleaderstyle_list() assert [row["name"] for row in rows] == ["ISO"] and rows[0]["arrow_size"] == 2.5 + + +# ── the MCP tools (SECTION 18) ────────────────────────────────────────────── + +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", +} + + +class _Ctx: + """Enough of a fastmcp Context for a direct tool call (tests/test_capabilities.py).""" + + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *_args, **_kwargs): + return None + + +async def test_style_tools_are_registered_and_grouped_under_styles(): + import server + + names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)} + assert STYLE_TOOLS <= names + groups = await server._tool_groups() + assert STYLE_TOOLS <= set(groups["styles"]), "the style tag must win over query/create/modify" + for other in ("layers", "drawing", "entity_query", "entity_creation", "entity_modification"): + assert not STYLE_TOOLS & set(groups.get(other, [])), other + + +async def test_dimstyle_tools_resolve_the_preset_then_write(backend): + import server + + ctx = _Ctx(backend) + created = await server.dimstyle_create( + name="ISO-25", preset="iso-25", overrides={"dimdec": 3}, set_current=True, ctx=ctx + ) + assert created["current"] is True and created["values"]["DIMDEC"] == 3 + listed = await server.dimstyle_list(ctx=ctx) + assert listed["count"] == 2 and listed["current"] == "ISO-25" + modified = await server.dimstyle_modify(name="ISO-25", overrides={"DIMTXT": 3.5}, ctx=ctx) + assert modified["changed"] == {"DIMTXT": [2.5, 3.5]} + switched = await server.dimstyle_set_current(name="Standard", ctx=ctx) + assert switched["previous"] == "ISO-25" and switched["current"] == "Standard" + + +async def test_dimstyle_create_tool_refuses_an_unknown_override_before_writing(backend): + import server + + ctx = _Ctx(backend) + with pytest.raises(ValueError, match="DIMFOO"): + await server.dimstyle_create(name="X", overrides={"DIMFOO": 1}, ctx=ctx) + with pytest.raises(ValueError, match="preset"): + await server.dimstyle_create(name="X", preset="din", ctx=ctx) + assert (await server.dimstyle_list(ctx=ctx))["count"] == 1 + + +async def test_textstyle_and_mleaderstyle_tools_round_trip(backend): + import server + + ctx = _Ctx(backend) + made = await server.textstyle_create(name="ISOCP", font="isocp", set_current=True, ctx=ctx) + assert made["font_resolved"] is True + assert (await server.textstyle_list(ctx=ctx))["current"] == "ISOCP" + assert (await server.textstyle_set_current(name="Standard", ctx=ctx))["previous"] == "ISOCP" + leader = await server.mleaderstyle_create( + name="ISO", preset="iso", overrides={"arrow_size": 3}, ctx=ctx + ) + assert leader["values"]["arrow_size"] == 3.0 and leader["textstyle_created"] is False + assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2 + with pytest.raises(ValueError, match="preset"): + await server.mleaderstyle_create(name="X", preset="din", ctx=ctx) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..9402ac1 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -517,8 +517,12 @@ async def test_tool_group_sizes_are_unchanged(): when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under - `pid` rather than under their secondary `query` / `create` tags. Every - other number here has been unchanged since the snapshot was taken. + `pid` rather than under their secondary `query` / `create` tags. `styles` + appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, + text and multileader style tools; the `style` tag sits ahead of `layer` + in `_GROUP_TAG_PRIORITY` so they never file under their secondary + `query` / `create` / `modify` tags. Every other number here has been + unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -537,10 +541,11 @@ async def test_tool_group_sizes_are_unchanged(): "pid": 9, "premium": 12, "solids": 5, + "styles": 9, "system": 7, "templates": 2, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 166 + assert sum(sizes.values()) == 175 From 03a5067d838ce4f42275e08ec0ca1e7b5a285234 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:17:30 +0300 Subject: [PATCH 10/13] fix(task-11): live text styles write TrueType by typeface, refuse missing fonts and arrowhead blocks before any write, un-narrow the dimension walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings against the COM styles block, all measured on AutoCAD 2026 (scratch documents from Documents.Add, closed without saving): 1. textstyle_create: `TextStyle.fontFile = "arial.ttf"` raises 'Filer error' for every bare TrueType name (bundled presets included) after TextStyles.Add has run, leaving a stub with an empty font that a retry meets as "already exists"; a full path is accepted but the STYLE record then carries the machine path. `SetFont("Arial", bold, italic, 0, 0)` — the STYLE dialog's own route — stores the clean `arial.ttf`. The font is now located before Add (`_com_locate_font`: support path, then the Windows font folders), a TrueType is written by the family/subfamily read from its `name` table (`_truetype_face`), an SHX by the name given, an unlocatable file is refused with nothing written, and a write ActiveX still refuses after Add deletes the entry it added. 2. _ensure_com_textstyle goes through the same helper, so DIMTXSTY / text_style ARIAL and ISOCPEUR are created on the first call with a real font instead of failing once and then silently binding a fontless style. 3. dimstyle_create / dimstyle_modify check a user DIMBLK/DIMBLK1/DIMBLK2 against doc.Blocks before any write (`_com_require_arrowhead_blocks`, the headless `_require_arrowhead_blocks` twin, same message and table-spelling rewrite); a write that fails after DimStyles.Add restores the previous ActiveDimStyle (discarding the unsaved overrides) and deletes the half-made style and any preset text style the call created. 4. _com_dimensions_using un-narrows each `Layouts.Item(i).Block.Item(p)` — declared IAcadEntity*, so `.StyleName` raised AttributeError under a makepy cache and the blanket except reported `[]` — and swallows nothing. The fake in tests/test_styles.py now models the measured ActiveX rules (bare TrueType fontFile refused, SetFont by installed typeface, SetVariable refusing an unknown arrowhead, IAcadEntity narrowing on block items, Delete) so the old code cannot pass; nine tests cover the four findings. SECTION 18 docstrings state the live engine's refusal rule for fonts and the pre-write arrowhead check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 349 +++++++++++++++++++++++++++---- server.py | 38 ++-- tests/test_styles.py | 441 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 763 insertions(+), 65 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 76fe4bc..d111acb 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -10,6 +10,7 @@ import asyncio import io import logging import math +import os import sys import time import uuid @@ -314,31 +315,261 @@ def _same_dimvar(old: Any, new: Any) -> bool: def _com_dimensions_using(doc, style_name: str) -> list[str]: - """Handles of every dimension, in every layout, whose StyleName is ``style_name``.""" + """Handles of every dimension, in every layout, whose StyleName is ``style_name``. + + ``IAcadBlock.Item`` is declared ``IAcadEntity*`` (acax25enu.tlb), so with a + makepy cache present every member comes back narrowed to ``IAcadEntity`` + and ``.StyleName`` — a member of the dimension classes, not of the entity + base — raises ``AttributeError``. Measured live (AutoCAD 2026): a rotated + dimension drawn with ``PROBE-ANSI`` answered ``[]`` because a blanket + ``except`` turned that AttributeError into "no dimension uses it". Each + item is therefore un-narrowed (``_com_unnarrow``), and nothing is + swallowed: a read that fails is an error the caller sees, not an empty + list reported as fact. + """ 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: + obj = _com_unnarrow(block.Item(position)) + if "Dimension" not in str(obj.ObjectName): continue + if str(obj.StyleName).lower() == wanted: + handles.append(str(obj.Handle)) 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() +#: File suffixes AutoCAD hands to the Windows font system rather than its +#: SHX loader (see ``_com_locate_font``). +_TRUETYPE_SUFFIXES = frozenset({".ttf", ".ttc", ".otf"}) + + +def _com_font_folders() -> list[Path]: + """Every folder a font file is looked for, in order. + + AutoCAD's support path (``Preferences.Files.SupportPath``, which carries + the install's ``fonts`` folder with the SHX shape files), then the Windows + font folders — machine-wide ``%WINDIR%\\Fonts`` (where AutoCAD's installer + puts ISOCPEUR next to Arial; measured) and the per-user + ``%LOCALAPPDATA%\\Microsoft\\Windows\\Fonts``. + """ + folders: list[Path] = [] 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) + support = "" + folders.extend(Path(folder) for folder in support.split(";") if folder) + windir = os.environ.get("WINDIR") or os.environ.get("SystemRoot") + if windir: + folders.append(Path(windir) / "Fonts") + local = os.environ.get("LOCALAPPDATA") + if local: + folders.append(Path(local) / "Microsoft" / "Windows" / "Fonts") + return folders + + +def _com_font_path(font_file: str) -> Path | None: + """The file ``font_file`` names on this machine, or None when nobody has it. + + An absolute path must exist as given; a bare name is searched over + ``_com_font_folders``, and a name without a suffix also as ``.shx`` (the + SHX loader appends it: ``fontFile = "isocp"`` is accepted live and stored + as given). + """ + path = Path(font_file) + if path.is_absolute(): + return path if path.is_file() else None + names = [font_file] if path.suffix else [font_file, font_file + ".shx"] + for folder in _com_font_folders(): + for name in names: + candidate = folder / name + if candidate.is_file(): + return candidate + return None + + +def _truetype_face(path: Path) -> tuple[str, bool, bool] | None: + """``(family, bold, italic)`` from a TrueType/OpenType file's ``name`` table. + + The family (name ID 1) is the typeface Windows GDI — and therefore + AutoCAD's ``TextStyle.SetFont`` — knows the font by; the subfamily (ID 2) + says whether it is the bold and/or italic face, which ``SetFont`` takes + as flags. Windows/Unicode strings (platform 3, en-US preferred) win over + Macintosh Roman ones. A collection (``ttcf``) is read by its first font. + None for a file that is not an sfnt or carries no family name. + """ + import struct + + try: + data = path.read_bytes() + except OSError: + return None + try: + offset = struct.unpack(">I", data[12:16])[0] if data[:4] == b"ttcf" else 0 + num_tables = struct.unpack(">H", data[offset + 4 : offset + 6])[0] + name_table = None + for index in range(num_tables): + record = offset + 12 + 16 * index + if data[record : record + 4] == b"name": + name_table = struct.unpack(">II", data[record + 8 : record + 16]) + break + if name_table is None: + return None + table_offset, _length = name_table + count, strings = struct.unpack(">HH", data[table_offset + 2 : table_offset + 6]) + found: dict[tuple[int, int], str] = {} # (name id, priority) -> text + for index in range(count): + record = table_offset + 6 + 12 * index + platform, _encoding, language, name_id, length, start = struct.unpack( + ">HHHHHH", data[record : record + 12] + ) + if name_id not in (1, 2): + continue + raw = data[table_offset + strings + start : table_offset + strings + start + length] + if platform == 3: + priority = 0 if language == 0x409 else 1 + text = raw.decode("utf-16-be", "replace") + elif platform == 1: + priority = 2 + text = raw.decode("mac-roman", "replace") + else: + continue + found.setdefault((name_id, priority), text) + except (struct.error, IndexError): + return None + family = next((found[key] for key in sorted(found) if key[0] == 1), "").strip() + if not family: + return None + subfamily = next((found[key] for key in sorted(found) if key[0] == 2), "").lower() + return family, "bold" in subfamily, "italic" in subfamily or "oblique" in subfamily + + +class _ComFont: + """A font file located for the live engine: where it is and, for + TrueType, the typeface ``SetFont`` needs (None for an SHX).""" + + __slots__ = ("font_file", "path", "face") + + def __init__(self, font_file: str, path: Path, face: tuple[str, bool, bool] | None): + self.font_file = font_file + self.path = path + self.face = face + + +def _com_font_refusal(key: str, font_file: str, detail: str) -> ValueError: + from engineering.standards.textstyles import TEXT_PRESETS + + presets = sorted(font for font, _w, _o in TEXT_PRESETS.values()) + return ValueError( + f"{key}: font file {font_file!r} {detail}; ActiveX refuses a font it cannot open, " + "so the live engine refuses it before any write (headlessly it would be written and " + "reported font_resolved false) — copy the file to a support-path or Windows Fonts " + f"folder, or use a bundled preset ({presets})" + ) + + +def _com_locate_font(key: str, font_file: str) -> _ComFont: + """The font ``font_file`` names, resolved *before* ``TextStyles.Add`` — or a refusal. + + Asked first because ``TextStyle.fontFile`` on a file AutoCAD cannot open + raises ``DISP_E_EXCEPTION 'Filer error'`` (measured on AutoCAD 2026 for + ``nosuchfont.shx``) *after* the table already holds the new name with an + empty font — a stub a retry then meets as "already exists". A TrueType + file must also carry a readable family name, because that — not the file + name — is what the write uses (``_com_write_font``). + """ + path = _com_font_path(font_file) + if path is None: + raise _com_font_refusal( + key, font_file, "is not on AutoCAD's support path or in the Windows Fonts folder" + ) + face = None + if path.suffix.lower() in _TRUETYPE_SUFFIXES: + face = _truetype_face(path) + if face is None: + raise _com_font_refusal(key, font_file, f"({path}) is not a readable TrueType file") + return _ComFont(font_file, path, face) + + +def _com_write_font(style, font: _ComFont) -> None: + """Set ``style``'s font from a ``_com_locate_font`` result. + + Measured on AutoCAD 2026: ``fontFile = "isocp.shx"`` (an SHX on the + support path) is accepted and stored as given, so an SHX is written by + the name the caller gave. A bare TrueType name — ``"arial.ttf"``, + ``"isocpeur.ttf"``, bundled presets included — raises ``'Filer error'`` + although the files exist; the full path is accepted but the STYLE record + then carries the machine path (``fontFile`` reads back + ``C:\\Windows\\Fonts\\arial.ttf``); ``SetFont("Arial", bold, italic, 0, 0)`` + — AutoCAD's own STYLE-dialog route — is accepted and reads back as the + clean ``arial.ttf``. A TrueType font is therefore written by its typeface, + and ``SetFont`` refusing a face Windows has not installed (``'Invalid + input'``) is the one failure that can still land after ``Add``; the + caller removes the entry it added. + """ + if font.face is None: + style.fontFile = font.font_file + else: + family, bold, italic = font.face + style.SetFont(family, bold, italic, 0, 0) + + +def _com_delete_quietly(obj, what: str) -> None: + """Best-effort ``Delete`` of a half-made table entry on an error path.""" + try: + obj.Delete() + except Exception as exc: + log.warning("could not remove half-made %s: %s", what, exc) + + +def _com_create_textstyle( + doc, name: str, font_file: str, *, width: float, oblique_deg: float, height: float, key: str +): + """``TextStyles.Add`` plus its font, width, oblique and height — or nothing. + + The font is located first (``_com_locate_font``) so a refusal precedes + the ``Add``; a write that still fails removes the entry it added, because + a STYLE row with an empty font that blocks a retry as "already exists" is + exactly the half-made state measured before this helper existed. + """ + font = _com_locate_font(key, font_file) + style = doc.TextStyles.Add(name) + try: + _com_write_font(style, font) + style.Width = float(width) + style.ObliqueAngle = deg2rad(oblique_deg) + style.Height = float(height) + except Exception: + _com_delete_quietly(style, f"text style {name!r}") + raise + return style + + +def _com_require_arrowhead_blocks(doc, values: dict[str, Any]) -> None: + """Refuse a user arrowhead block the drawing does not define — before any write. + + The live twin of the headless ``_require_arrowhead_blocks``: a built-in + (``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block, a user + name must be in ``doc.Blocks`` — AutoCAD's ``SetVariable("DIMBLK", + "NOSUCHBLOCK")`` raises ``DISP_E_EXCEPTION`` mid-write, after + ``DimStyles.Add`` and ``ActiveDimStyle`` (measured: the half-made style was + left *current*). The name is rewritten to the block table's own spelling, + the rule the headless engine follows. + """ + from engineering.standards.dimstyles import ARROWHEAD_BLOCKS + + for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"): + name = values.get(var) + if not name or name in ARROWHEAD_BLOCKS: + continue + block = _com_named(doc.Blocks, name) + if block is None: + 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] = str(block.Name) _MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE" @@ -427,11 +658,9 @@ def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, boo 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 + _com_create_textstyle( + doc, name.upper(), font_file, width=width, oblique_deg=oblique, height=0.0, key=refusal_key + ) return name.upper(), True @@ -4307,21 +4536,39 @@ class ComBackend(AutoCADBackend): 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. + # Every refusal precedes the first write: a user arrowhead block + # must exist (SetVariable("DIMBLK", ...) would otherwise raise + # mid-loop, after Add and ActiveDimStyle), and the text style must + # exist before DIMTXSTY can name it. + _com_require_arrowhead_blocks(doc, typed) 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: + try: + 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 + except Exception: + # Restoring the previous style discards the unsaved overrides + # (AutoCAD's own Restore rule) and frees the new entry for + # Delete, so the drawing is left exactly as it was found — + # the half-made style is never the operator's current style. doc.ActiveDimStyle = previous + _com_delete_quietly(style, f"dimension style {clean!r}") + if textstyle_created: + created = _com_named(doc.TextStyles, txsty) + if created is not None: + _com_delete_quietly(created, f"text style {txsty!r}") + raise _regen() return { "ok": True, @@ -4354,19 +4601,27 @@ class ComBackend(AutoCADBackend): f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; " "create it first with textstyle_create" ) + _com_require_arrowhead_blocks(doc, typed) 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) + try: + 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) + except Exception: + # Re-activating a style discards its unsaved overrides, so a + # failed write leaves the target style as it was saved and the + # operator's current style as it was found. + doc.ActiveDimStyle = style if not switched else previous + raise if switched: doc.ActiveDimStyle = previous using = _com_dimensions_using(doc, str(style.Name)) @@ -4443,18 +4698,28 @@ class ComBackend(AutoCADBackend): 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"] + # Unlike the headless engine, which stores a name nobody can find + # and says `font_resolved: false`, ActiveX refuses a font it + # cannot open — so the file is located before TextStyles.Add and + # a font AutoCAD cannot see is refused with nothing written. + style = _com_create_textstyle( + doc, + spec["name"], + spec["font_file"], + width=spec["width_factor"], + oblique_deg=spec["oblique_deg"], + height=spec["height"], + key="font", + ) 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"]), + # Always true here: a font the live engine could not locate + # was refused above, never written. + "font_resolved": True, "current": bool(set_current), } diff --git a/server.py b/server.py index 9e22520..5e0723a 100644 --- a/server.py +++ b/server.py @@ -6883,8 +6883,13 @@ async def dimstyle_create( DIMCLRD/E/T, DIMSAH, DIMBLK1/2, DIMCEN); a value outside its range (DIMDEC 0-8, sizes > 0, DIMTAD 0-4, DIMDSEP one character, lineweights an AutoCAD code); a name that already exists (use `dimstyle_modify`); a - DIMTXSTY that is neither an existing text style nor a bundled preset - (ISOCP, ISOCPEUR, ARIAL, ROMANS — those are created, `textstyle_created`). + user DIMBLK/DIMBLK1/DIMBLK2 naming a block the drawing does not define + (built-in arrowheads need none); a DIMTXSTY that is neither an existing + text style nor a bundled preset (ISOCP, ISOCPEUR, ARIAL, ROMANS — those + are created on both engines, `textstyle_created`; live, a preset whose + font file the seat lacks is refused the same way, nothing written). On + the live engine a write ActiveX still refuses after the style is added + is rolled back — previous style current, the half-made entry deleted. With `set_current` the next dimension carries the style on both engines. """ from engineering.standards.dimstyles import resolve_dimstyle @@ -6915,10 +6920,11 @@ async def dimstyle_modify( says they still show the old style until redrawn (AutoCAD re-renders on the next regen). Refuses a missing style, an empty `overrides`, a key outside the whitelist, a value outside its range, a DIMTXSTY that does - not exist — all before any write. On the live engine, editing a - non-current style makes it current for the duration of the call and the - previous style is restored (its unsaved overrides are discarded, which is - AutoCAD's own DIMSTYLE rule). + not exist, a user arrowhead block the drawing does not define — all + before any write. On the live engine, editing a non-current style makes + it current for the duration of the call and the previous style is + restored — also when a write fails midway (its unsaved overrides are + discarded, which is AutoCAD's own DIMSTYLE rule). """ await ctx.info(f"Modifying dimension style {name!r}") return await _backend(ctx).dimstyle_modify(name, overrides) @@ -6982,12 +6988,17 @@ async def textstyle_create( ) -> dict: """Create a text style. - A font that is neither a bundled preset nor on the engine's font path is - *written* and reported `font_resolved: false` (AutoCAD substitutes at - open; the headless renderer falls back) — never refused, because a DXF - stores only the name. Refuses, before any write: a name that exists, an - empty font, `width_factor <= 0`, `height < 0`, an oblique angle outside - ±85°. + Headlessly a font nobody can find is *written* and reported + `font_resolved: false` (AutoCAD substitutes at open; the headless + renderer falls back), because a DXF stores only the name. The live + engine cannot do that: ActiveX refuses a font AutoCAD cannot open, so a + file that is neither on its support path nor in the Windows Fonts folder + is refused there before any write (the message names the folders and the + presets); a TrueType font is written by its typeface (`SetFont`, what the + STYLE dialog does — the record carries `arial.ttf`, never a machine + path), an SHX by the file name. Refuses, before any write on both + engines: a name that exists, an empty font, `width_factor <= 0`, + `height < 0`, an oblique angle outside ±85°. """ await ctx.info(f"Text style {name!r} with font {font!r}") return await _backend(ctx).textstyle_create( @@ -7055,7 +7066,8 @@ async def mleaderstyle_create( unknown preset; an override key outside the four; `arrow_size` / `text_height <= 0`, `landing_gap < 0`; a name that exists; a `text_style` that is neither present nor a bundled preset (ISOCP, ISOCPEUR, ARIAL, - ROMANS — those are created, `textstyle_created`). + ROMANS — those are created on both engines, `textstyle_created`; live, a + preset whose font file the seat lacks is refused, nothing written). """ from engineering.standards.mleaderstyles import resolve_mleaderstyle diff --git a/tests/test_styles.py b/tests/test_styles.py index 7bb8eaa..aebf085 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -11,6 +11,7 @@ restores that style's saved DIM* settings. from __future__ import annotations import types +from pathlib import Path import ezdxf import pytest @@ -470,28 +471,119 @@ async def test_mleaderstyle_is_not_a_capability_boundary_on_either_engine(backen # ── live engine, against a fake ActiveX surface ────────────────────────────── +#: The font files the fake seat's font folder holds: the SHX presets (empty +#: files — the SHX loader is never read here) and the TrueType presets as +#: minimal sfnt files whose ``name`` table carries the family, plus a bold +#: face and txt.shx. The ``com_backend`` fixture writes them to a tmp folder. +FAKE_SHX_FONTS = ("isocp.shx", "romans.shx", "txt.shx") +FAKE_TTF_FONTS = { + "arial.ttf": ("Arial", "Regular"), + "arialbd.ttf": ("Arial", "Bold"), + "isocpeur.ttf": ("ISOCPEUR", "Regular"), + "orphan.ttf": ("Orphan Face", "Regular"), # a file Windows has not installed +} +#: The typefaces the fake Windows font system has installed — what SetFont accepts. +FAKE_INSTALLED_FACES = {"Arial": "arial", "ISOCPEUR": "isocpeur"} + + +def minimal_sfnt(family: str, subfamily: str) -> bytes: + """A TrueType file reduced to its table directory and a ``name`` table + with Windows/Unicode (platform 3, en-US) family and subfamily records — + enough for ``_truetype_face`` and nothing else.""" + import struct + + records, strings = b"", b"" + for name_id, text in ((1, family), (2, subfamily)): + raw = text.encode("utf-16-be") + records += struct.pack(">HHHHHH", 3, 1, 0x409, name_id, len(raw), len(strings)) + strings += raw + header = struct.pack(">HHH", 0, 2, 6 + len(records)) + name_table = header + records + strings + directory = struct.pack(">IHHHH", 0x00010000, 1, 16, 0, 0) + directory += b"name" + struct.pack(">III", 0, 12 + 16, len(name_table)) + return directory + name_table + + +class _FilerError(RuntimeError): + """``TextStyle.fontFile`` / ``SetVariable("DIMBLK", ...)`` failing the way + ActiveX does: ``com_error (-2147352567, 'Exception occurred.', (0, None, + 'Filer error', ...))`` — a generic error the caller cannot catch by type.""" + + class _FakeStyle: + """An AcadDimStyle / AcadTextStyle: ``Name``, ``CopyFrom`` and — for a text + style — ``fontFile``, ``Width``, ``ObliqueAngle``, ``Height``. The + ``fontFile`` setter models what AutoCAD 2026 measured: a bare SHX name on + the support path is accepted and stored as given; a bare *TrueType* name + is refused with a Filer error even when the file exists on the support + path (``"arial.ttf"``, ``"isocpeur.ttf"``); a full path to an existing + file is accepted; a file nobody has is refused. A refused write leaves + the previous value (``""`` on a fresh entry) — the stub state the + backend must never leave behind.""" + + support_folder: Path | None = None # set by the com_backend fixture + installed_faces: dict[str, str] = FAKE_INSTALLED_FACES # typeface -> file stem + def __init__(self, name, calls): self.Name = name self._calls = calls - self.fontFile = "txt.shx" + self._font = "txt.shx" self.Width = 1.0 self.ObliqueAngle = 0.0 self.Height = 0.0 + self.deleted = False self.saved: dict | None = None # the DIM* values this style holds, once saved + @property + def fontFile(self): + return self._font + + @fontFile.setter + def fontFile(self, value): + self._calls.append(("fontFile", self.Name, value)) + path = Path(value) + if path.is_absolute(): + accepted = path.is_file() + elif path.suffix.lower() in (".ttf", ".ttc", ".otf"): + accepted = False # a bare TrueType name: Filer error, measured + else: + folder = _FakeStyle.support_folder + accepted = folder is not None and (folder / value).is_file() + if not accepted: + raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))") + self._font = value + + def SetFont(self, typeface, bold, italic, charset, pitch): + # AcadTextStyle.SetFont: a typeface Windows has installed is accepted + # and fontFile reads back as its file name (measured: SetFont("Arial") + # -> 'arial.ttf'); anything else is 'Invalid input'. + self._calls.append(("SetFont", self.Name, typeface, bold, italic, charset, pitch)) + stem = self.installed_faces.get(typeface) + if stem is None: + raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Invalid input'))") + self._font = stem + ("bd" if bold else "") + ("i" if italic else "") + ".ttf" + 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) + def Delete(self): + # IAcadObject.Delete: the owning collection drops the entry. + self._calls.append(("Delete", self.Name)) + self.deleted = True + class _FakeCollection: def __init__(self, kind, calls, *names): self._kind = kind self._calls = calls - self.entries = [_FakeStyle(name, calls) for name in names] + self._all = [_FakeStyle(name, calls) for name in names] + + @property + def entries(self): + return [style for style in self._all if not style.deleted] @property def Count(self): @@ -503,11 +595,30 @@ class _FakeCollection: def Add(self, name): self._calls.append((f"{self._kind}.Add", name)) style = _FakeStyle(name, self._calls) - self.entries.append(style) + self._all.append(style) return style +class _FakeBlocks: + """``doc.Blocks``: Count / Item / Name over the block definitions.""" + + def __init__(self, *names): + self.entries = [types.SimpleNamespace(Name=name) for name in names] + + @property + def Count(self): + return len(self.entries) + + def Item(self, index): + return self.entries[index] + + class _FakeBlock: + """A layout's ``Block``: ``IAcadBlock.Item`` is declared ``IAcadEntity*``, + so each member comes back narrowed to the entity base (see + ``_NarrowedToIAcadEntity``) — ``StyleName`` is a dimension member and is + unreachable until un-narrowed.""" + def __init__(self, *entities): self.entities = list(entities) @@ -516,7 +627,7 @@ class _FakeBlock: return len(self.entities) def Item(self, index): - return self.entities[index] + return _NarrowedToIAcadEntity(self.entities[index]) class _FakeLayouts: @@ -599,6 +710,51 @@ class _NarrowedToIAcadObject: ) +class _NarrowedToIAcadEntity(_NarrowedToIAcadObject): + """What pywin32 hands back for ``IAcadBlock.Item`` with a makepy cache: + the gen_py ``IAcadEntity`` class — IAcadObject's members plus the entity + base's — and nothing of the runtime class. On a dimension, ``StyleName`` + raises ``AttributeError: 'IAcadEntity' object has no attribute + 'StyleName'`` (measured live on AutoCAD 2026).""" + + IACADENTITY_MEMBERS = frozenset( + { + "Color", + "EntityTransparency", + "Hyperlinks", + "Layer", + "Linetype", + "LinetypeScale", + "Lineweight", + "Material", + "PlotStyleName", + "TrueColor", + "Visible", + "ArrayPolar", + "ArrayRectangular", + "Copy", + "GetBoundingBox", + "Highlight", + "IntersectWith", + "Mirror", + "Mirror3D", + "Move", + "Rotate", + "Rotate3D", + "ScaleEntity", + "TransformBy", + "Update", + } + ) + + def __getattr__(self, key): + if key in self.IACADENTITY_MEMBERS: + return getattr(self._oleobj_, key) + if key in self.IACADOBJECT_MEMBERS: + return getattr(self._oleobj_, key) + raise AttributeError(f"'IAcadEntity' object has no attribute {key!r}") + + class _FakeDictionary: """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects and AddObject(keyword, class_name), the documented VBA route. Item and @@ -692,6 +848,7 @@ class _FakeDocument: ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C" ) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) + self.Blocks = _FakeBlocks("*Model_Space", "*Paper_Space", "MyArrow") self.Dictionaries = _FakeDictionaries( self.calls, {"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")}, @@ -708,6 +865,11 @@ class _FakeDocument: def SetVariable(self, name, value): self.calls.append(("SetVariable", name, value)) + if name in ("DIMBLK", "DIMBLK1", "DIMBLK2") and value and not value.startswith("_"): + # AutoCAD refuses a user arrowhead naming no block, mid-write + # (measured: DISP_E_EXCEPTION from SetVariable("DIMBLK", ...)). + if value.lower() not in {b.Name.lower() for b in self.Blocks.entries}: + raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))") self.variables[name] = value @property @@ -736,17 +898,28 @@ class _FakeDocument: @pytest.fixture -def com_backend(monkeypatch): +def com_backend(monkeypatch, tmp_path): pytest.importorskip("win32com.client", reason="pywin32 not installed") from backends import com_backend as module + # The fake seat's only font folder: the four presets plus txt.shx, as + # empty files. The host's Windows Fonts folder is deliberately out of + # reach so the tests measure the backend's resolution, not the machine. + support = tmp_path / "Fonts" + support.mkdir() + for font in FAKE_SHX_FONTS: + (support / font).write_bytes(b"") + for font, (family, subfamily) in FAKE_TTF_FONTS.items(): + (support / font).write_bytes(minimal_sfnt(family, subfamily)) + monkeypatch.setattr(_FakeStyle, "support_folder", support) document = _FakeDocument() app = types.SimpleNamespace( - Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath="")) + Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support))) ) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_regen", lambda: None) + monkeypatch.setattr(module, "_com_font_folders", lambda: [support]) backend = module.ComBackend() async def _run_inline(func, *args, **kwargs): @@ -762,10 +935,10 @@ async def test_com_create_sets_variables_then_copies_from_the_document(com_backe 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"], ( + assert names.index("TextStyles.Add") < names.index("DimStyles.Add"), ( "the text style exists before DIMTXSTY names it" ) - assert document.calls[2] == ("ActiveDimStyle", "ISO-25") + assert document.calls[names.index("DimStyles.Add") + 1] == ("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" @@ -865,8 +1038,8 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend): 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 + odd = await backend.textstyle_create("ODD", "romans.shx", 3.5, 0.8, 15.0) + assert odd["font_resolved"] is True 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] == [ @@ -1087,3 +1260,251 @@ async def test_textstyle_and_mleaderstyle_tools_round_trip(backend): assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2 with pytest.raises(ValueError, match="preset"): await server.mleaderstyle_create(name="X", preset="din", ctx=ctx) + + +# ── live engine: the four Task 11 review findings ──────────────────────────── + + +def _com_names(collection): + return [entry.Name for entry in collection.entries] + + +async def test_com_textstyle_create_writes_truetype_by_typeface_and_shx_by_name(com_backend): + """Measured on AutoCAD 2026: `fontFile = "arial.ttf"` raises 'Filer error' + (bundled presets included) and a full path stores the machine path in the + STYLE record, while `SetFont("Arial", ...)` stores the clean `arial.ttf`. + The fake refuses a bare TrueType name the same way, so a backend that + still wrote one could not pass this.""" + backend, document = com_backend + arial = await backend.textstyle_create("ARIAL", "arial") + assert arial == { + "ok": True, + "name": "ARIAL", + "font": "arial.ttf", + "font_resolved": True, + "current": False, + } + eur = await backend.textstyle_create("ISOCPEUR", "isocpeur.ttf") + bold = await backend.textstyle_create("HEAD", "arialbd.ttf", height=5.0) + shx = await backend.textstyle_create("ISOCP", "ISOCP") + assert eur["font"] == "isocpeur.ttf" and bold["font"] == "arialbd.ttf" + font_writes = [c for c in document.calls if c[0] in ("fontFile", "SetFont")] + assert font_writes == [ + ("SetFont", "ARIAL", "Arial", False, False, 0, 0), + ("SetFont", "ISOCPEUR", "ISOCPEUR", False, False, 0, 0), + ("SetFont", "HEAD", "Arial", True, False, 0, 0), + ("fontFile", "ISOCP", "isocp.shx"), + ], "TrueType by typeface (bold from the subfamily), SHX by the name given" + rows = {row["name"]: row["font"] for row in await backend.textstyle_list()} + assert rows == { + "ARIAL": "arial.ttf", + "ISOCPEUR": "isocpeur.ttf", + "HEAD": "arialbd.ttf", + "ISOCP": "isocp.shx", + "Standard": "txt.shx", + }, "what the STYLE records carry: file names, never a machine path" + assert shx["font_resolved"] is True + + +async def test_com_textstyle_create_refuses_an_unlocatable_font_before_add(com_backend): + """Before this fix `TextStyles.Add` ran first, `fontFile` then raised, and + the table kept the name with an empty font — so a retry with a good font + was refused 'already exists'. The refusal now precedes the Add, nothing is + written, and the retry succeeds.""" + backend, document = com_backend + with pytest.raises(ValueError, match=r"font: font file 'nosuchfont\.shx' is not on") as info: + await backend.textstyle_create("PROBE", "nosuchfont.shx") + assert "before any write" in str(info.value) and "isocp.shx" in str(info.value) + with pytest.raises(ValueError, match="font"): + await backend.textstyle_create("PROBE", r"C:\nosuch\folder\arial.ttf") + assert document.calls == [], "nothing reached ActiveX" + assert _com_names(document.TextStyles) == ["Standard"] + retry = await backend.textstyle_create("PROBE", "romans.shx") + assert retry["ok"] is True and document.TextStyles.entries[-1].fontFile == "romans.shx" + + +async def test_com_textstyle_create_removes_its_entry_when_the_write_still_fails(com_backend): + """A TrueType file that exists but whose face Windows has not installed + is the one refusal `SetFont` can still raise after the Add ('Invalid + input'). The error propagates — never a silent stub — and the entry the + backend added is deleted, so the name is free again.""" + backend, document = com_backend + with pytest.raises(_FilerError, match="Invalid input"): + await backend.textstyle_create("ORPHAN", "orphan.ttf") + assert _com_names(document.TextStyles) == ["Standard"] + assert ("Delete", "ORPHAN") in document.calls + again = await backend.textstyle_create("ORPHAN", "arial.ttf") + assert again["ok"] is True, "the failed create left no 'already exists' behind" + garbage = _FakeStyle.support_folder / "broken.ttf" + garbage.write_bytes(b"not an sfnt at all") + with pytest.raises( + ValueError, match=r"font: font file 'broken\.ttf' .* not a readable TrueType" + ): + await backend.textstyle_create("BROKEN", "broken.ttf") + assert _com_names(document.TextStyles) == ["Standard", "ORPHAN"], ( + "an unreadable TrueType file is refused before the Add" + ) + + +async def test_com_dimstyle_create_creates_the_truetype_presets_for_dimtxsty(com_backend): + """Finding 2: `DIMTXSTY: ARIAL` used to raise on the live engine and leave + an ARIAL entry with no font, so the *second* call reported ok with a + dimension style whose text style had no font. Every preset is created + through the same typeface route now, first time, and a preset whose file + the seat lacks is refused by DIMTXSTY before any write.""" + backend, document = com_backend + result = await backend.dimstyle_create( + "ANSI-AR", resolve_dimstyle("ansi", {"DIMTXSTY": "ARIAL"}) + ) + assert result["ok"] is True and result["textstyle_created"] is True + assert result["values"]["DIMTXSTY"] == "ARIAL" + arial = document.TextStyles.entries[-1] + assert (arial.Name, arial.fontFile) == ("ARIAL", "arial.ttf") + assert ("SetFont", "ARIAL", "Arial", False, False, 0, 0) in document.calls + eur = await backend.mleaderstyle_create( + "LDR", resolve_mleaderstyle("iso", {"text_style": "ISOCPEUR"}) + ) + assert eur["textstyle_created"] is True and eur["values"]["text_style"] == "ISOCPEUR" + assert document.TextStyles.entries[-1].fontFile == "isocpeur.ttf" + + (_FakeStyle.support_folder / "romans.shx").unlink() + document.calls.clear() + with pytest.raises(ValueError, match=r"DIMTXSTY: font file 'romans\.shx' is not on"): + await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "ROMANS"})) + assert document.calls == [] and _com_names(document.DimStyles) == ["Standard", "ANSI-AR"] + assert "ROMANS" not in _com_names(document.TextStyles), "no stub text style either" + + +async def test_com_dimstyle_create_refuses_a_missing_arrowhead_block_before_any_write(com_backend): + """Finding 3: `SetVariable("DIMBLK", "NOSUCHBLOCK")` raises mid-write on + the live engine, after Add and ActiveDimStyle — the half-made style was + left as the operator's current style. Refused first now, as headlessly.""" + backend, document = com_backend + with pytest.raises(ValueError, match=r"DIMBLK: block 'NOSUCHBLOCK' does not exist"): + await backend.dimstyle_create("BAD", resolve_dimstyle("iso-25", {"DIMBLK": "NOSUCHBLOCK"})) + with pytest.raises(ValueError, match="DIMBLK2"): + await backend.dimstyle_create("BAD", {"DIMSAH": 1, "DIMBLK2": "Nope"}) + assert document.calls == [] and _com_names(document.DimStyles) == ["Standard"] + assert document.ActiveDimStyle.Name == "Standard" + ok = await backend.dimstyle_create( + "ARROWS", resolve_dimstyle("iso-25", {"DIMBLK": "myarrow", "DIMBLK1": "_oblique"}) + ) + assert ok["values"]["DIMBLK"] == "MyArrow", "the block table's own spelling, as headlessly" + assert ("SetVariable", "DIMBLK", "MyArrow") in document.calls + assert ("SetVariable", "DIMBLK1", "_OBLIQUE") in document.calls, "a built-in needs no block" + + +async def test_com_dimstyle_create_rolls_back_a_write_that_fails_after_add( + com_backend, monkeypatch +): + """Whatever ActiveX refuses after DimStyles.Add (here: one SetVariable), + the drawing is left as it was found — previous style current with its + saved values, no half-made dimension style, no preset text style the + call created for it — and the error is the caller's, not swallowed.""" + backend, document = com_backend + real_set = document.SetVariable + + def failing_set(name, value): + if name == "DIMSCALE": + raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))") + real_set(name, value) + + monkeypatch.setattr(document, "SetVariable", failing_set) + with pytest.raises(_FilerError): + await backend.dimstyle_create("HALF", resolve_dimstyle("iso-25", None), set_current=True) + assert document.ActiveDimStyle.Name == "Standard" + assert document.variables == CURRENT_DIMVARS, "Restore discarded the unsaved overrides" + assert _com_names(document.DimStyles) == ["Standard"] + assert _com_names(document.TextStyles) == ["Standard"], "the ISOCP it created is gone too" + tail = [c for c in document.calls if c[0] in ("ActiveDimStyle", "Delete")] + assert tail == [ + ("ActiveDimStyle", "HALF"), + ("ActiveDimStyle", "Standard"), + ("Delete", "HALF"), + ("Delete", "ISOCP"), + ] + + +async def test_com_dimstyle_modify_refuses_a_missing_arrowhead_and_restores_on_failure( + com_backend, monkeypatch +): + backend, document = com_backend + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None)) + document.calls.clear() + with pytest.raises(ValueError, match=r"DIMBLK1: block 'Nope' does not exist"): + await backend.dimstyle_modify("ISO-25", {"DIMBLK1": "Nope"}) + assert document.calls == [], "refused before the style was even made current" + spelled = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "MYARROW"}) + assert spelled["changed"] == {"DIMBLK": ["", "MyArrow"]} + + real_set = document.SetVariable + + def failing_set(name, value): + if name == "DIMTXT": + raise _FilerError("(-2147352567, 'Exception occurred.', (0, None, 'Filer error'))") + real_set(name, value) + + monkeypatch.setattr(document, "SetVariable", failing_set) + document.calls.clear() + with pytest.raises(_FilerError): + await backend.dimstyle_modify("ISO-25", {"DIMDEC": 4, "DIMTXT": 9.0}) + assert document.ActiveDimStyle.Name == "Standard" + assert document.variables["DIMDEC"] == 2, "Restore discarded the DIMDEC override" + assert [c[0] for c in document.calls] == ["ActiveDimStyle", "SetVariable", "ActiveDimStyle"], ( + "DIMDEC was written, DIMTXT refused, the previous style restored; no CopyFrom" + ) + + +async def test_com_dimensions_using_style_reads_through_the_iacadentity_narrowing(com_backend): + """Finding 4: `IAcadBlock.Item` is declared `IAcadEntity*`, so with a + makepy cache `.StyleName` on a dimension raises AttributeError, and the + old blanket `except` reported `[]` for a style a dimension used. The fake + narrows the same way; the walker un-narrows and swallows nothing.""" + from backends.com_backend import _com_dimensions_using + + backend, document = com_backend + narrowed = document.Layouts.Item(0).Block.Item(0) + assert narrowed.ObjectName == "AcDbRotatedDimension" and narrowed.Handle == "2A" + with pytest.raises(AttributeError, match="'IAcadEntity' object has no attribute 'StyleName'"): + getattr(narrowed, "StyleName") # noqa: B009 — the read itself is what raises + assert _com_dimensions_using(document, "iso-25") == ["2A"] + assert _com_dimensions_using(document, "Standard") == ["2C"] + assert _com_dimensions_using(document, "NOPE") == [] + + class _BrokenDimension: + ObjectName = "AcDbAlignedDimension" + Handle = "2D" + + @property + def StyleName(self): + raise RuntimeError("RPC_E_CALL_REJECTED") + + document.Layouts.Item(1).Block.entities.append(_BrokenDimension()) + with pytest.raises(RuntimeError, match="RPC_E_CALL_REJECTED"): + _com_dimensions_using(document, "Standard") + + +async def test_truetype_face_reads_family_and_subfamily(tmp_path): + import struct + + from backends.com_backend import _com_font_path, _truetype_face + + regular = tmp_path / "r.ttf" + regular.write_bytes(minimal_sfnt("Segoe UI Semibold", "Regular")) + assert _truetype_face(regular) == ("Segoe UI Semibold", False, False) + bold_italic = tmp_path / "bi.ttf" + bold_italic.write_bytes(minimal_sfnt("Arial", "Bold Italic")) + assert _truetype_face(bold_italic) == ("Arial", True, True) + collection = tmp_path / "c.ttc" + inner = bytearray(minimal_sfnt("Cambria", "Regular")) + struct.pack_into(">I", inner, 12 + 8, 16 + 12 + 16) # table offsets are file-absolute + collection.write_bytes(b"ttcf" + struct.pack(">HHII", 1, 0, 1, 16) + inner) + assert _truetype_face(collection) == ("Cambria", False, False) + (tmp_path / "junk.ttf").write_bytes(b"\x00\x01\x00\x00" + b"\xff" * 40) + assert _truetype_face(tmp_path / "junk.ttf") is None + assert _truetype_face(tmp_path / "missing.ttf") is None + windows_arial = Path(r"C:\Windows\Fonts\arial.ttf") + if windows_arial.is_file(): + assert _truetype_face(windows_arial) == ("Arial", False, False) + assert _com_font_path(str(regular)) == regular + assert _com_font_path(str(tmp_path / "missing.ttf")) is None From 021a4eb43d65dce785881a720059448acec52bf3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:39:08 +0300 Subject: [PATCH 11/13] fix(task-11): dimensions_using_style is one filtered selection set, not a per-entity COM walk _com_dimensions_using walked every entity of every layout and, since the un-narrowing fix, re-dispatched each one through a dynamic proxy before reading ObjectName (~8-10 ms per entity), which put dimstyle_modify past the 60 s COM_CALL_TIMEOUT on a ~6k-entity drawing after its SetVariable + CopyFrom had already landed. It is now a single SelectionSet.Select(acSelectionSetAll, (0 . "DIMENSION") (3 .