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
This commit is contained in:
co-authored by
Claude Opus 5
parent
54fac471a9
commit
827719fdd0
@@ -10,12 +10,15 @@ the tools and the provenance tests read one copy.
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.standards.dimstyles import (
|
||||
ARROWHEAD_BLOCKS,
|
||||
DIM_VARIABLE_RANGES,
|
||||
DIM_VARIABLE_WHITELIST,
|
||||
PRESET_VARIABLES,
|
||||
PRESETS,
|
||||
canonical_arrowhead,
|
||||
check_dim_value,
|
||||
describe_preset,
|
||||
ezdxf_arrowhead,
|
||||
resolve_dimstyle,
|
||||
validate_overrides,
|
||||
)
|
||||
@@ -23,14 +26,17 @@ from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleader
|
||||
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||
|
||||
__all__ = [
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"MLEADER_PRESETS",
|
||||
"PRESETS",
|
||||
"PRESET_VARIABLES",
|
||||
"TEXT_PRESETS",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"resolve_font",
|
||||
"resolve_mleaderstyle",
|
||||
|
||||
@@ -25,8 +25,11 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = [
|
||||
"ANSI",
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"ISO_25",
|
||||
@@ -34,8 +37,10 @@ __all__ = [
|
||||
"PRESETS",
|
||||
"PRESET_SOURCES",
|
||||
"PRESET_VARIABLES",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"validate_overrides",
|
||||
]
|
||||
@@ -122,6 +127,23 @@ _LINEWEIGHT_TEXT = (
|
||||
)
|
||||
LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split())
|
||||
|
||||
#: AutoCAD's nineteen named built-in arrowheads, spelled the way the DIMBLK
|
||||
#: documentation, the ``$DIMBLK`` header, ActiveX and a loaded ezdxf DIMSTYLE
|
||||
#: all spell them: the block name, underscore first. The twentieth, closed
|
||||
#: filled, has no block name and is ``""`` (the spec §4.1 default).
|
||||
#: ``tests/test_standards_presets.py`` pins this set equal to ezdxf's
|
||||
#: ``ARROWS.__acad__`` so the two vocabularies cannot drift apart unseen.
|
||||
ARROWHEAD_BLOCKS: frozenset[str] = frozenset(
|
||||
"_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED "
|
||||
"_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 "
|
||||
"_ORIGIN _ORIGIN2 _SMALL".split()
|
||||
)
|
||||
|
||||
#: The spellings of the closed filled default a caller may send: AutoCAD's own
|
||||
#: "enter a single period to return to closed filled", and the block name
|
||||
#: ezdxf gives the arrow it draws for it.
|
||||
_CLOSED_FILLED_ALIASES = frozenset({".", "CLOSEDFILLED", "_CLOSEDFILLED"})
|
||||
|
||||
#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each
|
||||
#: value must sit in. The shape of each entry:
|
||||
#:
|
||||
@@ -131,7 +153,9 @@ LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_T
|
||||
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
|
||||
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
||||
#: * ``("char",)`` — exactly one character.
|
||||
#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow).
|
||||
#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of
|
||||
#: :data:`ARROWHEAD_BLOCKS` given with or without its underscore in any case,
|
||||
#: or a user block name under the symbol-name rule; see :func:`canonical_arrowhead`.
|
||||
#: * ``("name",)`` — a non-empty DXF symbol-table name.
|
||||
#: * a ``set`` — one of its members.
|
||||
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
@@ -148,7 +172,7 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
"DIMDSEP": ("char",),
|
||||
"DIMLUNIT": ("int", 1, 6),
|
||||
"DIMZIN": ("int", 0, 15), # a bitmask
|
||||
"DIMBLK": ("str",),
|
||||
"DIMBLK": ("arrow",),
|
||||
"DIMTXSTY": ("name",),
|
||||
"DIMLWD": LINEWEIGHT_CODES,
|
||||
"DIMLWE": LINEWEIGHT_CODES,
|
||||
@@ -167,17 +191,56 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
"DIMCLRE": ("int", 0, 256),
|
||||
"DIMCLRT": ("int", 0, 256),
|
||||
"DIMSAH": {0, 1},
|
||||
"DIMBLK1": ("str",),
|
||||
"DIMBLK2": ("str",),
|
||||
"DIMBLK1": ("arrow",),
|
||||
"DIMBLK2": ("arrow",),
|
||||
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
||||
}
|
||||
|
||||
DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES)
|
||||
|
||||
#: DXF forbids these in a symbol-table name; the same rule ``security.py``
|
||||
#: applies to block and layer names, restated here so this module stays free
|
||||
#: of fastmcp.
|
||||
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||
|
||||
def canonical_arrowhead(key: str, value: Any) -> str:
|
||||
"""``value`` as the arrowhead name DIMBLK stores, or ``TypeError`` /
|
||||
``ValueError`` naming ``key``.
|
||||
|
||||
``""``, ``"."``, ``CLOSEDFILLED`` and ``_CLOSEDFILLED`` are the closed
|
||||
filled default and come back as ``""``. A built-in is accepted with or
|
||||
without its underscore in any case (``oblique``, ``_Dot``) and comes back
|
||||
as its block name (``_OBLIQUE``, ``_DOT``) — AutoCAD's documented spelling,
|
||||
what ActiveX takes verbatim and what ezdxf reports after a load. Anything
|
||||
else is a user block name and must obey the symbol-name rule; whether the
|
||||
block exists is the backend's question, asked against the open drawing.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if value == "":
|
||||
return ""
|
||||
text = value.strip()
|
||||
if text.upper() in _CLOSED_FILLED_ALIASES:
|
||||
return ""
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f'{key}: {value!r} is blank; use "" for the closed filled arrowhead or a block name'
|
||||
)
|
||||
upper = text.upper()
|
||||
candidate = upper if upper.startswith("_") else "_" + upper
|
||||
if candidate in ARROWHEAD_BLOCKS:
|
||||
return candidate
|
||||
return check_name(key, text, what="block name")
|
||||
|
||||
|
||||
def ezdxf_arrowhead(canonical: str) -> str:
|
||||
"""The name ezdxf's DIMSTYLE takes for a :func:`canonical_arrowhead` value.
|
||||
|
||||
ezdxf's ``ARROWS`` vocabulary drops the underscore (``_DOT`` → ``DOT``,
|
||||
``""`` stays ``""``) and its export raises ``DXFValueError`` on ``_DOT``
|
||||
because no block of that name exists until it creates one from ``DOT``. A
|
||||
user block is passed through unchanged. The COM engine needs no mapping:
|
||||
AutoCAD takes the canonical spelling.
|
||||
"""
|
||||
if canonical in ARROWHEAD_BLOCKS:
|
||||
return canonical[1:]
|
||||
return canonical
|
||||
|
||||
|
||||
def _number(key: str, value: Any) -> float:
|
||||
@@ -226,21 +289,15 @@ def check_dim_value(name: str, value: Any) -> Any:
|
||||
if not low <= number <= high:
|
||||
raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}")
|
||||
return int(number)
|
||||
if kind == "arrow":
|
||||
return canonical_arrowhead(key, value)
|
||||
if kind == "name":
|
||||
return check_name(key, value, what="style name")
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if kind == "char":
|
||||
if len(value) != 1:
|
||||
raise ValueError(f"{key}: {value!r} must be exactly one character")
|
||||
return value
|
||||
if kind == "name":
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError(f"{key}: a style name cannot be empty")
|
||||
bad = sorted(set(text) & _ILLEGAL_NAME_CHARS)
|
||||
if bad:
|
||||
raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
|
||||
return text
|
||||
return value # "str"
|
||||
if len(value) != 1: # "char"
|
||||
raise ValueError(f"{key}: {value!r} must be exactly one character")
|
||||
return value
|
||||
|
||||
|
||||
def validate_overrides(overrides: dict | None) -> dict[str, Any]:
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"]
|
||||
|
||||
MLEADER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
@@ -20,8 +22,6 @@ MLEADER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
|
||||
MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height")
|
||||
|
||||
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||
|
||||
|
||||
def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
@@ -51,14 +51,7 @@ def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str
|
||||
f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}"
|
||||
)
|
||||
if name == "text_style":
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("text_style: a text style name cannot be empty")
|
||||
bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS)
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"text_style: {value!r} contains characters DXF forbids in a name: {bad}"
|
||||
)
|
||||
values[name] = value.strip()
|
||||
values[name] = check_name(name, value, what="text style name")
|
||||
elif name == "landing_gap":
|
||||
values[name] = _positive(name, value, allow_zero=True)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The DXF symbol-table name rule, in one place, for the standards data.
|
||||
|
||||
``security.illegal_symbol_name_chars`` is the same rule at the tool layer; it
|
||||
sits behind a fastmcp import and this package stays free of fastmcp, so the
|
||||
rule is restated here and ``tests/test_standards_presets.py`` pins the two
|
||||
character sets equal. A style, block or font name that passes here is one
|
||||
ezdxf will save and AutoCAD will load; ezdxf's own check fires at
|
||||
``doc.write``, long after a create tool has already said ``ok``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["ILLEGAL_NAME_CHARS", "check_name", "illegal_name_chars"]
|
||||
|
||||
#: Characters DXF forbids in a symbol-table name, plus the whitespace that
|
||||
#: ends a SendCommand segment — byte-for-byte ``security._FORBIDDEN_SYMBOL_CHARS``.
|
||||
ILLEGAL_NAME_CHARS: frozenset[str] = frozenset('<>/\\":;?*|,=`') | frozenset("\n\r\t")
|
||||
|
||||
|
||||
def illegal_name_chars(name: str) -> list[str]:
|
||||
"""The characters in ``name`` a DXF symbol-table name cannot hold, sorted."""
|
||||
return sorted({c for c in name if c in ILLEGAL_NAME_CHARS or ord(c) < 32})
|
||||
|
||||
|
||||
def check_name(key: str, value: Any, *, what: str = "name") -> str:
|
||||
"""``value`` as a stripped, non-empty, legal symbol-table name, or a
|
||||
``TypeError`` / ``ValueError`` naming ``key`` (``what`` words the refusal)."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError(f"{key}: a {what} cannot be empty")
|
||||
bad = illegal_name_chars(text)
|
||||
if bad:
|
||||
raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
|
||||
return text
|
||||
@@ -12,7 +12,11 @@ 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",
|
||||
@@ -31,7 +35,20 @@ TEXT_PRESETS: dict[str, tuple[str, float, float]] = {
|
||||
#: AutoCAD's own limit for the STYLE command's obliquing angle.
|
||||
MAX_OBLIQUE_DEG = 85.0
|
||||
|
||||
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')
|
||||
|
||||
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]:
|
||||
@@ -67,26 +84,21 @@ def validate_textstyle(
|
||||
) -> 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°."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name: a text style name cannot be empty")
|
||||
clean = name.strip()
|
||||
bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS)
|
||||
if bad:
|
||||
raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}")
|
||||
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)
|
||||
for key, value in (
|
||||
("height", height),
|
||||
("width_factor", width_factor),
|
||||
("oblique_deg", oblique_deg),
|
||||
):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
if float(height) < 0.0:
|
||||
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 float(width_factor) <= 0.0:
|
||||
if width_value <= 0.0:
|
||||
raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
|
||||
if abs(float(oblique_deg)) > MAX_OBLIQUE_DEG:
|
||||
if abs(oblique_value) > MAX_OBLIQUE_DEG:
|
||||
raise ValueError(
|
||||
f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees"
|
||||
)
|
||||
@@ -94,7 +106,7 @@ def validate_textstyle(
|
||||
"name": clean,
|
||||
"font_file": font_file,
|
||||
"known": known,
|
||||
"height": float(height),
|
||||
"width_factor": float(width_factor),
|
||||
"oblique_deg": float(oblique_deg),
|
||||
"height": height_value,
|
||||
"width_factor": width_value,
|
||||
"oblique_deg": oblique_value,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user