feat(entity): entity_get_xdata / entity_set_xdata on both engines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 14:16:12 +03:00
co-authored by Claude Opus 5
parent a84cae2641
commit cda8b69dba
10 changed files with 489 additions and 11 deletions
+55
View File
@@ -3085,6 +3085,61 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
# ── extended entity data (XDATA) ─────────────────────────────────────────
async def entity_get_xdata(self, handle, app_name=None) -> dict:
from backends.xdata_specs import split_by_app, validate_app_name
wanted = validate_app_name(app_name) if app_name else ""
def _sync():
doc = _acad_doc()
ent = doc.HandleToObject(str(handle).strip().upper())
# GetXData("") returns every app's stream with the 1001 markers
# inline; a named app returns just that app's slice.
codes, values = ent.GetXData(wanted)
grouped = split_by_app(list(codes or []), list(values or []))
if wanted:
grouped = {k: v for k, v in grouped.items() if k == wanted}
return {"handle": handle, "xdata": grouped, "backend": "com"}
return await self._run(_sync)
async def entity_set_xdata(self, handle, app_name, values) -> dict:
from backends.xdata_specs import encode_values, validate_app_name
# Validate and type every value before any ActiveX call: a bad
# values[i] raises here and nothing is written.
app = validate_app_name(app_name)
tags = encode_values(values)
def _sync():
doc = _acad_doc()
ent = doc.HandleToObject(str(handle).strip().upper())
try:
doc.RegisteredApplications.Add(app)
except Exception as exc: # already registered
log.debug("RegisteredApplications.Add(%s): %s", app, exc)
# A bare 1001 marker with no data is how ActiveX removes an app's
# XDATA, so the empty case is the same call with an empty tail.
codes = [1001] + [code for code, _ in tags]
payload = [app] + [_apoint(*value) if code == 1010 else value for code, value in tags]
ent.SetXData(
_ai(codes),
win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, payload),
)
return {
"ok": True,
"handle": handle,
"app_name": app,
"value_count": len(tags),
"bytes": sum(len(str(v)) for _, v in tags),
"removed": not tags,
"backend": "com",
}
return await self._run(_sync)
async def block_create_from_entities(
self,
name,
+14
View File
@@ -156,3 +156,17 @@ class EntityQueryContract(ABC):
would cost more than the listing it is meant to make cheap.
"""
...
# ── extended entity data (XDATA) ─────────────────────────────────────────
@abstractmethod
async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
"""Extended entity data as ``{"handle", "xdata": {app: [values]}, "backend"}``.
``app_name=None`` returns every app; an absent app is ``{}``, not an error."""
...
@abstractmethod
async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
"""Replace the app's XDATA (registering the APPID if needed). Values are typed
by ``backends/xdata_specs.py``; an empty list removes the app's XDATA."""
...
+60
View File
@@ -4518,6 +4518,66 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
# ── extended entity data (XDATA) ─────────────────────────────────────────
async def entity_get_xdata(self, handle, app_name=None) -> dict:
from backends.xdata_specs import decode_values, validate_app_name
wanted = validate_app_name(app_name) if app_name else None
def _sync():
ent = self._get_entity(handle)
out: dict[str, list] = {}
if ent.xdata is not None:
apps = [wanted] if wanted else list(ent.xdata.data.keys())
for app in apps:
if not ent.has_xdata(app):
continue
tags = ent.get_xdata(app)
out[app] = decode_values((tag.code, tag.value) for tag in tags)
return {"handle": handle, "xdata": out, "backend": "ezdxf"}
return await self._async(_sync)
async def entity_set_xdata(self, handle, app_name, values) -> dict:
from backends.xdata_specs import encode_values, validate_app_name
# Validate and type every value before touching the document: a bad
# values[i] raises here and nothing is written.
app = validate_app_name(app_name)
tags = encode_values(values)
def _sync():
doc = self._require_doc()
ent = self._get_entity(handle)
if not tags:
ent.discard_xdata(app)
self._mark_dirty()
return {
"ok": True,
"handle": handle,
"app_name": app,
"value_count": 0,
"bytes": 0,
"removed": True,
"backend": "ezdxf",
}
if app not in doc.appids:
doc.appids.add(app)
ent.set_xdata(app, tags)
self._mark_dirty()
return {
"ok": True,
"handle": handle,
"app_name": app,
"value_count": len(tags),
"bytes": sum(len(str(v)) for _, v in tags),
"removed": False,
"backend": "ezdxf",
}
return await self._async(_sync)
async def block_create_from_entities(
self,
name,
+102
View File
@@ -0,0 +1,102 @@
"""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.
"""
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
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}"
)
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