Files
autocad-mcp-pro/engineering/standards/textstyles.py
T
Umut CANandClaude Opus 5 827719fdd0 fix(task-9): finite text-style numbers, one symbol-name rule, canonical DIMBLK arrowheads
Three review findings on the standards data layer, each a value the layer
let through that a backend would then write as a silent wrong number or a
drawing that can no longer be saved:

* validate_textstyle never checked math.isfinite, unlike its siblings; nan
  and inf passed every comparison and reached the STYLE record verbatim.
  It now has the same _number helper dimstyles and mleaderstyles use.
* The three private _ILLEGAL_NAME_CHARS copies omitted \n \r \t that
  security._FORBIDDEN_SYMBOL_CHARS holds, so 'a\nb' was accepted as a text
  style, DIMTXSTY or leader text style and ezdxf refused the save. The rule
  now lives once in engineering/standards/names.py (fastmcp-free), the
  three modules import it, and a test pins its character set equal to
  security's and its output equal to illegal_symbol_name_chars.
* DIMBLK/DIMBLK1/DIMBLK2 were ("str",): any string went through, and the
  test pinned '_OBLIQUE', which ezdxf's writer refuses at doc.write while
  the COM engine wants exactly that spelling. They are now ("arrow",):
  canonical_arrowhead accepts a built-in with or without its underscore in
  any case (and '', '.', CLOSEDFILLED for the closed filled default), emits
  AutoCAD's documented block-name spelling (also what ezdxf reports after a
  load), and holds user block names to the symbol-name rule; ARROWHEAD_BLOCKS
  is pinned equal to ezdxf's ARROWS vocabulary, and ezdxf_arrowhead is the
  one translation the headless writer needs, proved by exporting all twenty
  headlessly and reading each back as its canonical name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:33:05 +03:00

113 lines
4.3 KiB
Python

"""Text style presets: the four fonts the standards data refers to by name.
``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique
degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape
file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the
general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face
ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font
*name*, so an unknown file is never refused here: ``resolve_font`` says
whether it is a bundled preset, the backend adds whether its font path has
it, and the caller sees ``font_resolved``.
"""
from __future__ import annotations
import math
from pathlib import PurePath
from typing import Any
from engineering.standards.names import check_name
__all__ = [
"MAX_OBLIQUE_DEG",
"TEXT_PRESETS",
"resolve_font",
"validate_textstyle",
]
TEXT_PRESETS: dict[str, tuple[str, float, float]] = {
"ISOCP": ("isocp.shx", 1.0, 0.0),
"ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
"ARIAL": ("arial.ttf", 1.0, 0.0),
"ROMANS": ("romans.shx", 1.0, 0.0),
}
#: AutoCAD's own limit for the STYLE command's obliquing angle.
MAX_OBLIQUE_DEG = 85.0
def _number(key: str, value: Any) -> float:
"""A finite number, or ``TypeError`` / ``ValueError`` naming ``key``.
``nan`` and ``inf`` pass every ``<`` / ``>`` check, and a backend writing
them verbatim leaves ``50\\nnan`` in the STYLE record — the silent wrong
number the contract forbids.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{key}: {value!r} is not a finite number")
return number
def resolve_font(font: str) -> tuple[str, bool]:
"""``(font_file, known)``: a preset name or file resolves to the bundled
file with ``known=True``; anything else is passed through with ``False``.
Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem
(``isocp``), case-insensitively. Refuses an empty string or control
characters — the only inputs a DXF cannot hold.
"""
if not isinstance(font, str):
raise TypeError(f"font: expected a string, got {type(font).__name__}")
text = font.strip()
if not text:
raise ValueError("font: a font file name cannot be empty")
if any(ord(ch) < 32 for ch in text):
raise ValueError("font: control characters are refused")
upper = text.upper()
if upper in TEXT_PRESETS:
return TEXT_PRESETS[upper][0], True
for font_file, _width, _oblique in TEXT_PRESETS.values():
if upper in (font_file.upper(), PurePath(font_file).stem.upper()):
return font_file, True
return text, False
def validate_textstyle(
name: str,
font: str,
height: float = 0.0,
width_factor: float = 1.0,
oblique_deg: float = 0.0,
) -> dict:
"""The typed request both engines write, or a ``ValueError``/``TypeError``
naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal;
the width factor must be positive; the oblique angle is AutoCAD's ±85°;
every number must be finite. The name obeys the DXF symbol-name rule
(``engineering.standards.names``) — this is the only gate on the
``textstyle_create`` path, and a name that passes must be one the drawing
can still be saved with."""
clean = check_name("name", name, what="text style name")
font_file, known = resolve_font(font)
height_value = _number("height", height)
width_value = _number("width_factor", width_factor)
oblique_value = _number("oblique_deg", oblique_deg)
if height_value < 0.0:
raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'")
if width_value <= 0.0:
raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
if abs(oblique_value) > MAX_OBLIQUE_DEG:
raise ValueError(
f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees"
)
return {
"name": clean,
"font_file": font_file,
"known": known,
"height": height_value,
"width_factor": width_value,
"oblique_deg": oblique_value,
}