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:
co-authored by
Claude Opus 5
parent
a60f801205
commit
03a5067d83
+307
-42
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user