Files
autocad-mcp-pro/backends/block_specs.py
T
Umut CANandClaude Opus 5 b464bcdaf5 fix(task-4): block_define dedupes requested layers case-insensitively so one layer in two spellings is created and reported once
DXF and ActiveX layer tables are case-insensitive, but block_define collected
the primitives' layers with a case-sensitive set. A request naming 'Alpha' and
'ALPHA' with create_layers=True made ezdxf add the first, then raise
DXFTableEntryError on the second after the write — layer left behind, no block,
surfaced as an internal error instead of a named refusal. COM ran Layers.Add
twice, got the existing layer back the second time, and reported two layers
created for one that appeared; the two engines diverged and the refusal message
without create_layers named the same layer twice.

referenced_layers() in backends/block_specs.py keys the set on the folded name
and keeps the first spelling; both engines consume it. _FakeLayers in the
hardening tests now matches Item/Add case-insensitively and returns the existing
record on a duplicate Add, as the ActiveX table does, so the COM path is pinned.

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

279 lines
12 KiB
Python

# backends/block_specs.py
"""Typed primitive specs for ``block_define``, validated before any write.
Both engines consume the same normalised specs, so a malformed request is
refused identically on COM and ezdxf and nothing is written on either. Every
error names the offending entry (``entities[i]`` / ``attdefs[i]``) and key.
"""
from __future__ import annotations
import math
import re
from security import illegal_symbol_name_chars
ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid")
TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center")
#: Checked with ``fullmatch`` — ``$`` alone would accept a trailing newline, which
#: ezdxf then strips on write, turning two "distinct" tags into one duplicate.
ATTDEF_TAG_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,30}$")
_REQUIRED = {
"line": ("x1", "y1", "x2", "y2"),
"circle": ("cx", "cy", "r"),
"arc": ("cx", "cy", "r", "start_deg", "end_deg"),
"polyline": ("points",),
"text": ("text", "x", "y", "height"),
"solid": ("points",),
}
_POSITIVE = {"r", "height"}
def _scalar(value, where: str, *, positive: bool = False) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{where} must be a number, got {value!r}")
try:
number = float(value)
except OverflowError as exc: # a 400-digit JSON integer is an int, not a float
raise TypeError(f"{where} must be finite") from exc
if not math.isfinite(number):
raise TypeError(f"{where} must be finite")
if positive and number <= 0:
raise TypeError(f"{where} must be > 0, got {number}")
return number
def _number(spec: dict, key: str, where: str) -> float:
return _scalar(spec.get(key), f"{where}: {key!r}", positive=key in _POSITIVE)
def _points(
spec: dict, where: str, *, minimum: int, maximum: int | None = None
) -> list[tuple[float, float]]:
raw = spec.get("points")
if not isinstance(raw, (list, tuple)) or len(raw) < minimum:
raise TypeError(f"{where}: 'points' must be a list of at least {minimum} [x, y] pairs")
if maximum is not None and len(raw) > maximum:
raise TypeError(f"{where}: 'points' must have at most {maximum} pairs")
out: list[tuple[float, float]] = []
for i, pt in enumerate(raw):
if not isinstance(pt, (list, tuple)) or len(pt) != 2:
raise TypeError(f"{where}: points[{i}] must be an [x, y] pair")
out.append(
(
_scalar(pt[0], f"{where}: points[{i}].x"),
_scalar(pt[1], f"{where}: points[{i}].y"),
)
)
return out
def _align(spec: dict, where: str) -> str:
align = spec.get("align", "left")
if align not in TEXT_ALIGNMENTS:
raise TypeError(f"{where}: 'align' must be one of {TEXT_ALIGNMENTS}, got {align!r}")
return align
def _layer(spec: dict, where: str) -> str:
layer = spec.get("layer", "0")
if not isinstance(layer, str) or not layer.strip():
raise TypeError(f"{where}: 'layer' must be a non-empty string")
layer = layer.strip()
# Gate the DXF name rule here rather than letting ezdxf raise DXFValueError
# on `add_*(dxfattribs={"layer": ...})` mid-loop, after the block exists and
# earlier primitives are already written (and, with overwrite, the old
# definition is already gone).
offenders = illegal_symbol_name_chars(layer)
if offenders:
shown = ", ".join(repr(c) for c in offenders)
raise TypeError(f"{where}: 'layer' is not a legal DXF layer name (contains {shown})")
return layer
def _one_line(value, where: str) -> str:
"""A single-line DXF string (TEXT content, ATTDEF prompt/default).
ezdxf's DXFAttr fixer (``fix_one_line_text``) silently strips line breaks
and a trailing ``^`` on write, logged at DEBUG only, so the declared value
and the value read back would differ; COM writes the raw string, so the two
engines would also diverge. Refuse it here, before any write, using ezdxf's
own ``is_valid_one_line_text`` rule plus the other C0 controls DXF text
cannot carry.
"""
if not isinstance(value, str):
raise TypeError(f"{where} must be a string")
controls = sorted({c for c in value if ord(c) < 32})
if controls:
shown = ", ".join(repr(c) for c in controls)
raise TypeError(
f"{where} must be a single line without control characters (contains {shown})"
)
if value.endswith("^"):
raise TypeError(f"{where} must not end with '^' (DXF strips a trailing caret)")
return value
def validate_entity_specs(entities) -> list[dict]:
"""Normalise ``block_define`` primitive specs; refuse the whole list on the first defect."""
if not isinstance(entities, (list, tuple)) or not entities:
raise ValueError(
"block_define: 'entities' is empty — a block that draws nothing is refused"
)
out: list[dict] = []
for i, spec in enumerate(entities):
where = f"entities[{i}]"
if not isinstance(spec, dict):
raise TypeError(f"{where}: must be an object")
kind = spec.get("type")
if kind not in ALLOWED_TYPES:
raise TypeError(f"{where}: 'type' must be one of {ALLOWED_TYPES}, got {kind!r}")
for key in _REQUIRED[kind]:
if key not in spec:
raise TypeError(f"{where}: {kind} requires {key!r}")
norm: dict = {"type": kind, "layer": _layer(spec, where)}
if kind == "line":
for key in ("x1", "y1", "x2", "y2"):
norm[key] = _number(spec, key, where)
elif kind == "circle":
for key in ("cx", "cy", "r"):
norm[key] = _number(spec, key, where)
elif kind == "arc":
for key in ("cx", "cy", "r", "start_deg", "end_deg"):
norm[key] = _number(spec, key, where)
elif kind == "polyline":
pts = _points(spec, where, minimum=2)
closed = spec.get("closed", False)
if not isinstance(closed, bool):
raise TypeError(f"{where}: 'closed' must be a boolean")
bulges = spec.get("bulges")
if bulges is not None:
if not isinstance(bulges, (list, tuple)) or len(bulges) != len(pts):
raise TypeError(f"{where}: 'bulges' must list one value per point")
bulges = [_scalar(b, f"{where}: bulges[{j}]") for j, b in enumerate(bulges)]
norm.update(points=pts, closed=closed, bulges=list(bulges) if bulges else None)
elif kind == "text":
norm.update(
text=_one_line(spec.get("text"), f"{where}: 'text'"),
x=_number(spec, "x", where),
y=_number(spec, "y", where),
height=_number(spec, "height", where),
rotation_deg=_scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
align=_align(spec, where),
)
elif kind == "solid":
norm["points"] = _points(spec, where, minimum=3, maximum=4)
out.append(norm)
return out
def validate_attdef_specs(attdefs) -> list[dict]:
"""Normalise ATTDEF specs: tag pattern, uniqueness, numeric fields, flags."""
if attdefs is None:
return []
if not isinstance(attdefs, (list, tuple)):
raise TypeError("block_define: 'attdefs' must be a list")
out: list[dict] = []
seen: set[str] = set()
for i, spec in enumerate(attdefs):
where = f"attdefs[{i}]"
if not isinstance(spec, dict):
raise TypeError(f"{where}: must be an object")
tag = spec.get("tag")
if not isinstance(tag, str) or not ATTDEF_TAG_RE.fullmatch(tag):
raise TypeError(f"{where}: 'tag' must match {ATTDEF_TAG_RE.pattern}, got {tag!r}")
if tag in seen:
raise TypeError(f"{where}: duplicate tag {tag!r}")
seen.add(tag)
prompt = _one_line(spec.get("prompt", tag), f"{where}: 'prompt'")
default = _one_line(spec.get("default", ""), f"{where}: 'default'")
invisible = spec.get("invisible", False)
if not isinstance(invisible, bool):
raise TypeError(f"{where}: 'invisible' must be a boolean")
out.append(
{
"tag": tag,
"prompt": prompt,
"default": default,
"x": _number(spec, "x", where),
"y": _number(spec, "y", where),
"height": _number(spec, "height", where),
"rotation_deg": _scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
"align": _align(spec, where),
"invisible": invisible,
}
)
return out
def validate_base_point(base_x, base_y) -> tuple[float, float]:
"""``block_define``'s base point as finite floats; ``TypeError`` names the key.
Only ``float()`` was applied before, so ``float("nan")`` was written into
the BLOCK record headlessly and would reach AutoCAD as a NaN VARIANT.
"""
return (
_scalar(base_x, "block_define: 'base_x'"),
_scalar(base_y, "block_define: 'base_y'"),
)
def referenced_layers(entities: list[dict]) -> list[str]:
"""The distinct layers ``block_define``'s primitives reference, other than ``0``.
DXF and ActiveX layer tables are case-insensitive, so the set is keyed on
the folded name and keeps the first spelling seen: a request naming
``Alpha`` and ``ALPHA`` wants *one* layer. Deduping case-sensitively let
ezdxf write ``ALPHA`` and then raise ``DXFTableEntryError`` on ``Alpha``
mid-call, and let COM report two layers created for one ``Layers.Add``
that took effect.
"""
seen: dict[str, str] = {}
for spec in entities:
layer = spec["layer"]
if layer != "0" and layer.lower() not in seen:
seen[layer.lower()] = layer
return sorted(seen.values())
def validate_attribute_values(attributes, where: str = "attributes") -> dict[str, str]:
"""``{TAG: value}`` for ``block_insert`` / ``block_set_attributes`` as the
strings that will be written, refused before any write.
``str(value)`` used to be applied to whatever arrived, so a JSON ``null``
became the text ``None``, ``true`` became ``True`` and an object became its
Python repr — silently, on both engines. A string is taken as is (one line,
the DXF rule ``_one_line`` enforces); an int or a finite float is written
with ``str`` (``101`` → ``"101"``, ``2.5`` → ``"2.5"``); ``bool``, ``None``,
lists and objects are refused by tag. ``None`` for the whole mapping means
"no attributes".
"""
if attributes is None:
return {}
if not isinstance(attributes, dict):
raise TypeError(f"{where} must be an object of {{TAG: value}} pairs")
out: dict[str, str] = {}
for tag, value in attributes.items():
if not isinstance(tag, str) or not tag.strip():
raise TypeError(f"{where}: every tag must be a non-empty string")
label = f"{where}[{tag!r}]"
if isinstance(value, str):
out[tag] = _one_line(value, label)
elif isinstance(value, bool) or value is None or not isinstance(value, (int, float)):
raise TypeError(f"{label} must be a string or a number, got {type(value).__name__}")
else:
if isinstance(value, float) and not math.isfinite(value):
raise TypeError(f"{label} must be finite")
out[tag] = str(value)
return out
def solid_vertices(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""DXF SOLID draws vtx0→vtx1→vtx3→vtx2, so a quad given in polygon order
(a, b, c, d) is stored as (a, b, d, c). Triangles are stored as given."""
pts = [(float(x), float(y)) for x, y in points]
if len(pts) == 4:
return [pts[0], pts[1], pts[3], pts[2]]
return pts