fix(task-11): live text styles write TrueType by typeface, refuse missing fonts and arrowhead blocks before any write, un-narrow the dimension walker

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 22:17:30 +03:00
co-authored by Claude Opus 5
parent a60f801205
commit 03a5067d83
3 changed files with 763 additions and 65 deletions
+307 -42
View File
@@ -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),
}
+25 -13
View File
@@ -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
+431 -10
View File
@@ -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