Files
autocad-mcp-pro/backends/xdata_specs.py
T
Umut CANandClaude Opus 5 41d6d3505a fix(task-4): refuse LF/CR in XDATA strings before any write
encode_values() accepted strings containing "\n" or "\r". ezdxf writes
1000-tag values verbatim, so the separator split the tag pair: the saved
DXF failed to reopen (DXFStructureError) and, because every undo and
transaction snapshot is also a DXF save, transaction_rollback broke after
a single such write. Both engines route through encode_values, so the
refusal (ValueError naming values[i]) now happens there, before either
backend touches the document. Only LF and CR split a DXF tag; tabs and
the other Unicode separators round-trip and stay allowed.

Tests: pure encoder refusal for \n, \r, \r\n; other control characters
still typed; ezdxf refuses-then-rollback-and-reopen round trip; COM fake
sees no SetXData / RegisteredApplications call.

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

113 lines
4.5 KiB
Python

"""XDATA value typing and limits, shared by both engines.
DXF XDATA is a list of (group code, value) pairs under a registered APPID.
This module is the one place that decides how a JSON value becomes a group
code and enforces the two limits AutoCAD enforces silently: 255 characters
per string and 16 KB per entity. It also refuses the one thing neither engine
can store: a line separator inside a string. DXF is a text format of
alternating "group code" / "value" lines, and ezdxf writes a 1000 tag verbatim,
so a LF or CR in the value splits the tag pair and the saved file -- and every
undo/transaction snapshot, which is also a DXF save -- becomes unreadable.
"""
from __future__ import annotations
import re
APP_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,30}$")
RESERVED_APPS = {"ACAD"}
MAX_STRING = 255
MAX_BYTES = 16 * 1024
INT_MIN, INT_MAX = -(2**31), 2**31 - 1
LINE_SEPARATORS = ("\n", "\r")
def validate_app_name(app) -> str:
if not isinstance(app, str) or not APP_NAME_RE.match(app):
raise ValueError(f"xdata: app name must match {APP_NAME_RE.pattern}, got {app!r}")
if app.upper() in RESERVED_APPS:
raise ValueError(f"xdata: app name {app!r} is reserved")
return app
def encode_values(values) -> list[tuple[int, object]]:
"""JSON values → (group code, value). Raises TypeError/ValueError; writes nothing."""
if not isinstance(values, (list, tuple)):
raise TypeError("xdata: values must be a list")
tags: list[tuple[int, object]] = []
size = 0
for i, value in enumerate(values):
where = f"values[{i}]"
if isinstance(value, bool):
raise TypeError(f"xdata: {where} booleans have no XDATA group code")
if isinstance(value, str):
if len(value) > MAX_STRING:
raise ValueError(
f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}"
)
if any(sep in value for sep in LINE_SEPARATORS):
raise ValueError(
f"xdata: {where} contains a newline; "
"line separators are not allowed in XDATA strings"
)
tags.append((1000, value))
size += len(value.encode("utf-8")) + 2
elif isinstance(value, int):
if not INT_MIN <= value <= INT_MAX:
raise ValueError(f"xdata: {where} is outside the 32-bit integer range")
tags.append((1071, int(value)))
size += 6
elif isinstance(value, float):
tags.append((1040, float(value)))
size += 10
elif isinstance(value, (list, tuple)) and len(value) in (2, 3):
if any(isinstance(c, bool) for c in value):
raise TypeError(f"xdata: {where} point coordinates must be numbers")
try:
coords = tuple(float(c) for c in value)
except (TypeError, ValueError) as exc:
raise TypeError(f"xdata: {where} point coordinates must be numbers") from exc
if len(coords) == 2:
coords = (coords[0], coords[1], 0.0)
tags.append((1010, coords))
size += 26
else:
raise TypeError(f"xdata: {where} must be a string, int, float or [x, y(, z)] point")
if size > MAX_BYTES:
raise ValueError(f"xdata: encoded size {size} bytes exceeds the 16 KB per-entity limit")
return tags
def decode_values(tags) -> list:
"""(group code, value) → JSON values. Skips the 1001 app marker and 1002 braces."""
out: list = []
for code, value in tags:
if code in (1001, 1002):
continue
if code == 1010 or code in (1011, 1012, 1013):
out.append([float(c) for c in value])
elif code in (1040, 1041, 1042):
out.append(float(value))
elif code in (1070, 1071):
out.append(int(value))
else:
out.append(str(value))
return out
def split_by_app(codes, values) -> dict[str, list]:
"""A flat ActiveX GetXData stream (1001 markers inline) → {app: values}."""
out: dict[str, list] = {}
current: str | None = None
pending: list[tuple[int, object]] = []
for code, value in zip(codes, values, strict=True):
if code == 1001:
if current is not None:
out[current] = decode_values(pending)
current, pending = str(value), []
else:
pending.append((code, value))
if current is not None:
out[current] = decode_values(pending)
return out