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