From 03a5067d838ce4f42275e08ec0ca1e7b5a285234 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:17:30 +0300 Subject: [PATCH] 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