merge: track-e group C

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-22 11:12:15 +03:00
co-authored by Claude Opus 5
14 changed files with 3881 additions and 42 deletions
+126 -5
View File
@@ -37,6 +37,7 @@ from .base import (
rad2deg,
shoelace_area,
)
from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties
log = logging.getLogger(__name__)
@@ -260,6 +261,30 @@ def _require_block_defined(doc, name):
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS}
#: `IAcadSummaryInfo` failure codes, as the `scode` in a `com_error`'s
#: excepinfo (measured on AutoCAD 2026; the description next to them is
#: English while the HRESULT text is localised, so the code is matched first).
_SUMMARYINFO_DUPLICATE_KEY = -2145386475 # AddCustomInfo: 'Duplicate key'
_SUMMARYINFO_KEY_NOT_FOUND = -2145386476 # Set/RemoveCustomByKey: 'Key not found'
def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> bool:
"""True when `exc` is the `IAcadSummaryInfo` failure `scode` / `description`.
`com_error.args[2]` is the excepinfo tuple ``(wCode, source, description,
helpfile, helpcontext, scode)``; a missing or foreign excepinfo is never a
match, so an unrelated COM failure keeps propagating.
"""
info = exc.args[2] if len(exc.args) > 2 else None
if not isinstance(info, tuple):
return False
code = info[5] if len(info) > 5 else None
desc = str(info[2] or "") if len(info) > 2 else ""
return code == scode or desc.strip().casefold() == description.casefold()
def _ensure_linetype_loaded(name: str) -> None:
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
@@ -1327,6 +1352,8 @@ class ComBackend(AutoCADBackend):
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
"registry_sysvar": FeatureCapability(True, "native"),
"dwgprops": FeatureCapability(True, "native"),
},
)
@@ -4942,30 +4969,47 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def system_get_variable(self, name) -> Any:
# GetVariable / SetVariable are members of AcadDocument, not of
# AcadApplication: `AutoCAD.Application.GetVariable` raises
# AttributeError on a live seat before anything reaches AutoCAD.
def _sync():
app = _acad_app()
return app.GetVariable(name)
doc = _acad_doc()
return doc.GetVariable(name)
return await self._run(_sync)
async def system_set_variable(self, name, value) -> dict:
def _sync():
app = _acad_app()
doc = _acad_doc() # the sysvar host is the document (see system_get_variable)
# Coerce to the sysvar's actual type (NEW-com-set-variable-coercion):
# AutoCAD rejects e.g. a string "0" for the integer OSMODE. Probe the
# current value's type and convert to match; pass through on failure.
coerced = value
# `payload` is what SetVariable receives. It differs from `coerced`
# only for point variables, whose VARIANT wrapper must never reach
# the returned dict: pydantic cannot serialise
# win32com.client.VARIANT, so the tool would report failure *after*
# AutoCAD had already applied the write.
payload = value
try:
current = app.GetVariable(name)
current = doc.GetVariable(name)
if isinstance(current, bool):
coerced = bool(int(value)) if isinstance(value, str) else bool(value)
elif isinstance(current, int):
coerced = int(float(value)) if isinstance(value, str) else int(value)
elif isinstance(current, float):
coerced = float(value)
elif isinstance(current, (tuple, list)):
# 2D/3D point variables (LIMMIN, GRIDUNIT, SNAPUNIT, ...) travel
# as VARIANT double arrays of the length AutoCAD itself reports;
# a bare Python tuple is marshalled as VT_VARIANT and rejected.
coords = [float(v) for v in value]
coords += [0.0] * (len(current) - len(coords))
coerced = coords[: len(current)]
payload = _av(coerced) if isinstance(current, (tuple, list)) else coerced
except Exception as exc:
log.debug("set_variable type probe for %s failed: %s", name, exc)
app.SetVariable(name, coerced)
doc.SetVariable(name, payload)
return {"ok": True, "variable": name, "value": coerced}
return await self._run(_sync)
@@ -5514,3 +5558,80 @@ class ComBackend(AutoCADBackend):
return _entity_info(xline)
return await self._run(_sync)
# ── settings (track E, group C) ───────────────────────────────────────────
async def drawing_properties_get(self) -> dict:
def _sync():
info = _acad_doc().SummaryInfo
summary = {
field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items()
}
custom: dict[str, str] = {}
for index in range(int(info.NumCustomInfo())):
key, value = info.GetCustomByIndex(index)
custom[str(key)] = str(value)
return {
"summary": summary,
"summary_available": True,
"custom": custom,
"backend": "com",
}
return await self._run(_sync)
async def drawing_properties_set(
self, summary: dict | None = None, custom: dict | None = None
) -> dict:
# Validated before any ActiveX call: a bad field or value raises here
# and SummaryInfo is never touched.
written, to_write, to_delete = validate_drawing_properties(summary, custom)
def _sync():
info = _acad_doc().SummaryInfo
# AutoCAD matches custom keys by its own simple per-character case
# compare (measured on 2026: AddCustomInfo("PROJECT") over an
# existing "Project" is 'Duplicate key', yet "Straße" and
# "STRASSE" are two keys -- Python's casefold() merges them, and a
# routing built on it sent "STRASSE" to SetCustomByKey, which
# failed 'Key not found' after the summary fields and the earlier
# keys were already applied). So only an exact spelling is routed
# from here; for everything else AutoCAD decides: AddCustomInfo's
# 'Duplicate key' means the key is there under another spelling
# and it is set instead, RemoveCustomByKey's 'Key not found' means
# it is not there and the delete is reported as not done. No
# routing guess can then half-write.
existing: set[str] = set()
for index in range(int(info.NumCustomInfo())):
key, _value = info.GetCustomByIndex(index)
existing.add(str(key))
for field, value in written.items():
setattr(info, _SUMMARY_ATTRS[field], value)
for key, value in to_write.items():
if key in existing:
info.SetCustomByKey(key, value)
continue
try:
info.AddCustomInfo(key, value)
except _COM_ERROR as exc:
if not _summaryinfo_error_is(exc, _SUMMARYINFO_DUPLICATE_KEY, "Duplicate key"):
raise
info.SetCustomByKey(key, value)
deleted = []
for key in to_delete:
try:
info.RemoveCustomByKey(key)
except _COM_ERROR as exc:
if not _summaryinfo_error_is(exc, _SUMMARYINFO_KEY_NOT_FOUND, "Key not found"):
raise
continue
deleted.append(key)
return {
"ok": True,
"summary_written": sorted(written),
"custom_written": sorted(to_write),
"custom_deleted": deleted,
"backend": "com",
}
return await self._run(_sync)
+476 -13
View File
@@ -3,10 +3,19 @@
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and
snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and
angular unit formats, and the current dimension / text style. Every key still
goes through ``system_get_variable`` / ``system_set_variable`` so the facade
stays backend-agnostic; the *engines* are where a variable's real home is
known (header, VPORT, the variable dictionary, or — headlessly — nowhere).
"""
from __future__ import annotations
import math
import re
from typing import Any
@@ -20,46 +29,97 @@ class SettingsContract:
"""Read (no args) or apply (with args) common drawing settings.
With ``settings=None`` returns a snapshot of every known setting. With a
dict, applies each provided key and returns ``{applied, errors, current}``.
dict, applies each provided key and returns ``{applied, changed,
errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value
actually moved (re-setting a value to itself is not a change).
Friendly keys: units, linear_precision, angular_precision, ltscale,
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
decimal_separator, zero_suppression, text_size, point_mode, point_size,
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
INSUNITS code) and `decimal_separator` accepts "." or ",".
osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing,
ortho, polar, polar_angle, psltscale, annotation_scale, linear_units,
angular_units, dimstyle, textstyle.
Note that a no-argument snapshot is one ``system_get_variable`` per key.
Free headless; a COM round trip each on a live seat."""
Refusals (per key, in ``errors``; the other keys still apply): an
unknown key; a value outside ``_SETTING_RANGES``; a malformed
``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file
headlessly (no OBJECTS section, so the value would vanish at save —
save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle``
on the headless engine (registry-saved — ``capability: registry_sysvar``;
a snapshot reports them as ``None`` there, a file holds no value);
``dimstyle`` / ``textstyle`` when the backend has no styles contract.
Note that a no-argument snapshot is one ``system_get_variable`` per key
(two for ``limits``). Free headless; a COM round trip each on a live
seat, and a write costs a read before and after it for ``changed``."""
if not settings:
snapshot: dict = {}
for key, (var, kind) in _SETTING_MAP.items():
for key, spec in _SETTING_MAP.items():
try:
raw = await self.system_get_variable(var)
snapshot[key] = await self._read_setting(key, spec)
except Exception as exc:
snapshot[key] = {"error": str(exc)}
continue
snapshot[key] = _decode_setting(key, kind, raw)
return {"ok": True, "settings": snapshot}
applied: dict = {}
changed: dict = {}
errors: dict = {}
for key, value in settings.items():
spec = _SETTING_MAP.get(key)
if spec is None:
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
continue
var, kind = spec
try:
encoded = _encode_setting(key, kind, value)
await self.system_set_variable(var, encoded)
before = await self._read_setting(key, spec)
await self._write_setting(key, spec, value)
after = await self._read_setting(key, spec)
applied[key] = value
if after != before:
changed[key] = [before, after]
except Exception as exc:
errors[key] = str(exc)
result = {"ok": not errors, "applied": applied}
result = {"ok": not errors, "applied": applied, "changed": changed}
if errors:
result["errors"] = errors
return result
async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any:
var, kind = spec
if kind == "limits":
low = await self.system_get_variable("LIMMIN")
high = await self.system_get_variable("LIMMAX")
if low is None or high is None:
return None
return [_point2(low), _point2(high)]
return _decode_setting(key, kind, await self.system_get_variable(var))
async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None:
var, kind = spec
if kind == "limits":
low, high = _encode_limits(key, value) # validated before either write
await self.system_set_variable("LIMMIN", low)
await self.system_set_variable("LIMMAX", high)
return
if kind == "name":
name = _encode_name(key, value)
setter = getattr(self, _STYLE_SETTERS[key], None)
if not callable(setter):
raise ValueError(
f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract "
f"absent); the current {key} can be read but not changed here."
)
await setter(name)
return
if kind == "bit":
flag = _encode_bool(key, value)
raw = await self.system_get_variable(var)
current = int(raw) if raw is not None else 0
mask = _BIT_MASKS[key]
await self.system_set_variable(var, (current | mask) if flag else (current & ~mask))
return
await self.system_set_variable(var, _encode_setting(key, kind, value))
# ---------------------------------------------------------------------------
# Helpers
@@ -86,10 +146,47 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
"text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"),
# OSMODE is registry-saved (a DXF R2000+ file has no $OSMODE), so the
# headless engine refuses it like the polar pair below.
"osmode": ("OSMODE", "int"),
"fillet_radius": ("FILLETRAD", "float"),
# ── track E: the environment a drawing lives in ──────────────────────
# `limits` is one friendly key over two variables; `_read_setting` and
# `_write_setting` special-case the kind, the variable named here is the
# first of the pair.
"limits": ("LIMMIN", "limits"),
# GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a
# DXF file — AutoCAD keeps them on the active VPORT — so the headless engine
# routes them there (see `EzdxfBackend.system_set_variable`).
"grid": ("GRIDMODE", "bool"),
"grid_spacing": ("GRIDUNIT", "spacing"),
"snap": ("SNAPMODE", "bool"),
"snap_spacing": ("SNAPUNIT", "spacing"),
"ortho": ("ORTHOMODE", "bool"),
# Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE
# — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP
# and POLARANG are registry-saved, so a headless file cannot hold them.
"polar": ("AUTOSNAP", "bit"),
"polar_angle": ("POLARANG", "degrees"),
"psltscale": ("PSLTSCALE", "bool"),
# CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header
# variable; CANNOSCALEVALUE is derived from it and read-only.
"annotation_scale": ("CANNOSCALE", "scale"),
"linear_units": ("LUNITS", "lunits"),
"angular_units": ("AUNITS", "aunits"),
# The current style names read through the header / GetVariable; a write
# delegates to the styles contract (`dimstyle_set_current` /
# `textstyle_set_current`) because DIMSTYLE is a read-only variable.
"dimstyle": ("DIMSTYLE", "name"),
"textstyle": ("TEXTSTYLE", "name"),
}
_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"}
#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16
#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.)
_BIT_MASKS = {"polar": 8}
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
_UNIT_TO_CODE: dict[str, int] = {
"unitless": 0,
@@ -111,6 +208,24 @@ _UNIT_TO_CODE: dict[str, int] = {
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
# LUNITS (linear unit format) and AUNITS (angular unit format) code tables.
_LUNITS_TO_CODE: dict[str, int] = {
"scientific": 1,
"decimal": 2,
"engineering": 3,
"architectural": 4,
"fractional": 5,
}
_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()}
_AUNITS_TO_CODE: dict[str, int] = {
"degrees": 0,
"dms": 1,
"grads": 2,
"radians": 3,
"surveyor": 4,
}
_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()}
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
#: on a string in the header — so the friendly key is where the translation
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
@@ -118,6 +233,111 @@ _CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5
#: the middle of every number on the sheet, so it is refused rather than stored.
_DECIMAL_MARKERS = (".", ",")
#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive.
SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
def parse_scale(text: Any) -> tuple[str, float, float]:
"""``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input.
Returns the canonical name (whitespace stripped, integers without a
trailing ``.0``), the paper units and the drawing units. The annotation
scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``.
"""
match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None
if match is None:
raise ValueError(
f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as "
"'1:1', '1:50' or '2:1'."
)
paper, drawing = float(match.group(1)), float(match.group(2))
if paper <= 0 or drawing <= 0:
raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.")
return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing
def scale_value(text: Any) -> float:
"""CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``."""
_, paper, drawing = parse_scale(text)
return paper / drawing
def _trim(number: float) -> str:
return str(int(number)) if number == int(number) else f"{number:g}"
def _point2(raw: Any) -> list[float]:
"""A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3)."""
try:
return [float(raw[0]), float(raw[1])]
except (TypeError, IndexError, ValueError) as exc:
raise ValueError(f"expected a point, got {raw!r}") from exc
def _finite(key: str, value: Any, what: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{key}: {what} must be a number, got {value!r}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{key}: {what} must be finite")
return number
def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]:
"""``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write."""
if (
not isinstance(value, (list, tuple))
or len(value) != 2
or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value)
):
raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}")
xmin = _finite(key, value[0][0], "xmin")
ymin = _finite(key, value[0][1], "ymin")
xmax = _finite(key, value[1][0], "xmax")
ymax = _finite(key, value[1][1], "ymax")
if xmax <= xmin or ymax <= ymin:
raise ValueError(
f"{key}: the upper-right corner must be above and right of the lower-left "
f"(got {[[xmin, ymin], [xmax, ymax]]})"
)
return (xmin, ymin), (xmax, ymax)
_TRUE_WORDS = {"1", "on", "true", "yes"}
_FALSE_WORDS = {"0", "off", "false", "no"}
def _encode_bool(key: str, value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
if isinstance(value, str):
word = value.strip().lower()
if word in _TRUE_WORDS:
return True
if word in _FALSE_WORDS:
return False
raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}")
def _encode_name(key: str, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{key}: expected a non-empty style name, got {value!r}")
return value.strip()
def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int:
if isinstance(value, (int, float)) and not isinstance(value, bool):
code = int(value)
if code in table.values():
return code
raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}")
name = str(value).strip().lower()
if name in table:
return table[name]
raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.")
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
@@ -131,6 +351,27 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any:
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "lunits":
return _encode_enum(key, value, _LUNITS_TO_CODE)
if kind == "aunits":
return _encode_enum(key, value, _AUNITS_TO_CODE)
if kind == "bool":
return 1 if _encode_bool(key, value) else 0
if kind == "scale":
name, _paper, _drawing = parse_scale(value)
return name
if kind == "spacing":
if isinstance(value, (list, tuple)):
if len(value) != 2:
raise ValueError(f"{key}: expected a number or [x, y], got {value!r}")
x = _in_range(key, _finite(key, value[0], "x"))
y = _in_range(key, _finite(key, value[1], "y"))
return (x, y)
step = _in_range(key, _finite(key, value, "spacing"))
return (step, step)
if kind == "degrees":
degrees = _in_range(key, _finite(key, value, "angle"))
return math.radians(degrees)
if kind == "char":
if isinstance(value, bool):
marker = ""
@@ -173,6 +414,10 @@ _SETTING_RANGES: dict[str, tuple[float, float]] = {
"ltscale": (1e-9, 1e6),
"point_size": (-100, 1e6), # negative is a percentage of the screen
"fillet_radius": (0, 1e6),
# track E
"grid_spacing": (1e-9, 1e9), # GRIDUNIT, strictly positive per axis
"snap_spacing": (1e-9, 1e9), # SNAPUNIT, strictly positive per axis
"polar_angle": (1e-9, 360), # POLARANG increment, degrees at the facade
}
@@ -200,6 +445,47 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "lunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")}
if kind == "aunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")}
if kind == "bool":
try:
return bool(int(raw))
except (TypeError, ValueError):
return raw
if kind == "bit":
try:
return bool(int(raw) & _BIT_MASKS[key])
except (TypeError, ValueError):
return raw
if kind == "degrees":
try:
return round(math.degrees(float(raw)), 6)
except (TypeError, ValueError):
return raw
if kind == "spacing":
try:
x, y = _point2(raw)
except ValueError:
return raw
return x if x == y else [x, y]
if kind == "scale":
try:
name, paper, drawing = parse_scale(raw)
except ValueError:
return {"name": str(raw), "value": None}
return {"name": name, "value": paper / drawing}
if kind == "name":
return str(raw)
if kind == "char":
try:
code = int(raw)
@@ -219,3 +505,180 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
except (TypeError, ValueError):
return raw
return raw
# ---------------------------------------------------------------------------
# Document properties (DWGPROPS) — validation shared by both engines
# ---------------------------------------------------------------------------
#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab.
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
#: The printable characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects
#: anywhere inside a custom key with ``Invalid key``. Measured on AutoCAD 2026
#: by sweeping every character 0x20-0x7E through ``AddCustomInfo`` on a scratch
#: document: exactly these thirteen are refused; every other printable ASCII
#: character, every control character (0x01-0x1F, including tab), internal
#: spaces, a no-break space and other unicode are accepted. Leading or
#: trailing whitespace (space, tab or no-break space) is refused separately.
_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|')
def custom_key_fold(key: str) -> str:
"""Fold a custom-property key the way AutoCAD compares them.
AutoCAD's ``AddCustomInfo`` / ``SetCustomByKey`` / ``RemoveCustomByKey``
match keys by a *simple* one-to-one case compare, not Unicode full case
folding. Measured on AutoCAD 2026 (``AddCustomInfo`` of the second
spelling over the first): ``Project``/``PROJECT``, ``Grün``/``GRÜN``,
``é``/``É``, ``ÿ``/``Ÿ``, ``ł``/``Ł``, ``σ``/``Σ``, ``я``/``Я`` and
``dž``/``DŽ`` are the same key (``Duplicate key``), while ``Straße``/
``STRASSE``, ``ẞ``/``ß``, ``kelvin``/``Kelvin`` (Kelvin sign), ``µ``/``Μ``,
``σς``/``ΣΣ`` and ``Dž``/``DŽ`` are distinct (both stored) — every one of
which Python's ``str.casefold()`` calls equal. So this folds each character
to its lowercase only when the mapping is a single character that
round-trips (``lower().upper() == upper()``, ``upper().lower() == lower()``
and the character is one of the two); anything else — ``ß`` (uppercases
to ``SS``), the final sigma ``ς``, the micro sign ``µ``, the Kelvin sign,
the title-case digraph ``Dž``, dotless ``ı`` and dotted ``İ`` — is kept as
is. The result never changes length. This is what the headless engine and
the validator match on; the live engine lets AutoCAD decide (see
``ComBackend.drawing_properties_set``).
"""
out: list[str] = []
for c in key:
lo, up = c.lower(), c.upper()
if (
len(lo) == 1
and len(up) == 1
and lo.upper() == up
and up.lower() == lo
and c in (lo, up)
):
out.append(lo)
else:
out.append(c)
return "".join(out)
def _check_custom_text(key: str, text: str, what: str) -> None:
"""A newline in a custom key or value corrupts the DXF on save.
ezdxf writes custom properties verbatim as ``9 / $CUSTOMPROPERTYTAG / 1 /
{key}`` and ``9 / $CUSTOMPROPERTY / 1 / {value}`` header lines — a line
break inside either text starts a new (invalid) group and the reopened
file fails with ``DXFStructureError``. Refused here so neither engine
writes it.
"""
if "\n" in text or "\r" in text:
raise ValueError(
f"custom[{key!r}]: the {what} contains a line break, which corrupts the "
"DXF on save (the whole drawing would fail to reopen, not just the "
"property). Custom properties are single-line."
)
def _check_custom_key_for_write(key: str) -> None:
"""Mirror AutoCAD's ``AddCustomInfo`` key syntax so the live engine cannot
refuse mid-write. Measured on AutoCAD 2026: ``Invalid key`` for a key with
leading/trailing whitespace (space, tab, no-break space) or any of
``_CUSTOM_KEY_FORBIDDEN`` anywhere in it — raised after the summary fields
and the earlier keys were already applied, while the headless engine
writes the same key without complaint. Only a *write* goes through
``AddCustomInfo``; ``RemoveCustomByKey`` does not validate syntax (measured:
``Key not found``, never ``Invalid key``), so a delete is not held to these
rules — a key that reached the drawing by other means (a DXF written by
ezdxf, say) must stay removable."""
if key != key.strip():
raise ValueError(
f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's "
"AddCustomInfo rejects it as 'Invalid key'. Strip the key."
)
bad = sorted(set(key) & _CUSTOM_KEY_FORBIDDEN)
if bad:
shown = " ".join(repr(c) for c in bad)
raise ValueError(
f"custom: key {key!r} contains {shown} — AutoCAD's AddCustomInfo rejects "
f"it as 'Invalid key' (refused characters: {''.join(sorted(_CUSTOM_KEY_FORBIDDEN))}). "
"Use another separator."
)
def validate_drawing_properties(
summary: dict | None, custom: dict | None
) -> tuple[dict[str, str], dict[str, str], list[str]]:
"""→ (summary fields to write, custom keys to write, custom keys to delete).
Refuses the whole request by name before either engine writes anything:
an unknown summary field is a ``ValueError``; a non-string summary value,
a non-string or empty custom key, or a custom value that is neither a
string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never
coerced with ``str()`` — a number the caller meant as text is theirs to
format.
Custom keys and values are also held to the rules the *other* engine would
enforce or the file format would break on, so the same call cannot succeed
on one engine and half-apply on the other (``ValueError`` naming the key):
* a key to *write* with leading or trailing whitespace, or containing any
of the thirteen characters ``" * , / : ; < = > ? \\ ` |`` — AutoCAD's
``AddCustomInfo`` rejects exactly these as ``Invalid key`` mid-write
(measured on AutoCAD 2026 over every printable ASCII character), after
the summary fields and the earlier keys were already applied; the
headless engine writes them without complaint, and AutoCAD then loads
them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey``
never validates syntax, and such keys do reach drawings by other routes,
so they must stay removable;
* two keys in one request that AutoCAD would treat as the same key — its
key compare is a simple one-to-one case compare (``custom_key_fold``;
measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project``
raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two
keys), so the second would fail mid-write on the live engine and write
a second tag headlessly;
* a key or value containing a line break (LF or CR) — ezdxf emits custom
properties unescaped, one header line per text, so the saved DXF is
corrupt and the whole drawing fails to reopen (``DXFStructureError``),
not just the property.
"""
if summary is not None and not isinstance(summary, dict):
raise TypeError("summary: must be an object of {field: text}")
written: dict[str, str] = {}
for field, value in (summary or {}).items():
if field not in SUMMARY_FIELDS:
raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})")
if value is None:
continue
if not isinstance(value, str):
raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}")
written[field] = value
if custom is not None and not isinstance(custom, dict):
raise TypeError("custom: must be an object of {key: text | null}")
to_write: dict[str, str] = {}
to_delete: list[str] = []
seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> the spelling seen first
for key, value in (custom or {}).items():
if not isinstance(key, str) or not key.strip():
raise TypeError(f"custom: key {key!r} must be a non-empty string")
_check_custom_text(key, key, "key")
folded = custom_key_fold(key)
if folded in seen:
raise ValueError(
f"custom: keys {seen[folded]!r} and {key!r} differ only by case — "
"AutoCAD compares custom keys with a simple per-character case "
"compare and calls these the same key (AddCustomInfo raises "
"'Duplicate key'). Mention the key once."
)
seen[folded] = key
if value is None:
to_delete.append(key)
elif isinstance(value, str):
_check_custom_key_for_write(key)
_check_custom_text(key, value, "value")
to_write[key] = value
else:
raise TypeError(
f"custom[{key!r}]: must be a string or null (null deletes the key), "
f"got {type(value).__name__}"
)
return written, to_write, to_delete
+371 -2
View File
@@ -39,6 +39,13 @@ from .base import (
normalize_lineweight,
shoelace_area,
)
from .contracts.settings import (
SUMMARY_FIELDS,
custom_key_fold,
parse_scale,
scale_value,
validate_drawing_properties,
)
from .quarantine import (
AbandonedCall,
DocumentQuarantineError,
@@ -59,6 +66,54 @@ __all__ = [
#: document that has been abandoned on repeatedly rather than once.
_MAX_ABANDONED_RECORDS = 10
#: System variables that AutoCAD does not keep in the HEADER section. Grid and
#: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14);
#: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`.
_VPORT_SYSVARS: dict[str, tuple[str, str]] = {
"GRIDMODE": ("grid_on", "flag"),
"SNAPMODE": ("snap_on", "flag"),
"GRIDUNIT": ("grid_spacing", "point"),
"SNAPUNIT": ("snap_spacing", "point"),
}
#: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved
#: in" column). A file has nowhere to keep them, so the headless engine refuses
#: a write with `capability: registry_sysvar` instead of storing a value that
#: would vanish, and reads report `None` rather than the value ezdxf's document
#: template happens to hold in memory; `engineering.standards.sysvars` pins this
#: set to its catalogue. OSMODE / ATTREQ / ATTDIA are the trap: ezdxf accepts
#: `doc.header["$OSMODE"]` (an R12 header variable, `maxdxf="AC1009"` in
#: `HEADER_VAR_MAP`) but never exports it for R2000+ - measured 2026-09-17: a
#: write of 4134 to a fresh AC1024 document read back `None` after save + open.
_UNSAVED_SYSVARS: frozenset[str] = frozenset(
{
"AUTOSNAP",
"POLARANG",
"POLARMODE",
"OSMODE",
"ATTREQ",
"ATTDIA",
"SAVETIME",
"FILEDIA",
"XREFCTL",
"PROXYSHOW",
"CMDECHO",
"HIGHLIGHT",
"HPNAME",
"HPSCALE",
"HPANG",
"DWGNAME",
"DWGPREFIX",
}
)
#: Header variables the DXF file stores in degrees (group code 50) while the
#: sysvar boundary speaks radians - the unit ActiveX `GetVariable` and AutoLISP
#: `getvar` use, so both engines report the same number. Measured on AutoCAD
#: 2026 (2026-09-17): `SETVAR ANGBASE 90` reads back 1.5707963267948966 over
#: COM and the DXF AutoCAD saves carries `$ANGBASE = 90.0`.
_HEADER_DEGREE_SYSVARS: frozenset[str] = frozenset({"ANGBASE"})
log = logging.getLogger(__name__)
@@ -1361,6 +1416,13 @@ class EzdxfBackend(AutoCADBackend):
False, reason="acis_generation_requires_live_autocad"
),
"lisp": FeatureCapability(False, reason="live_com_only"),
"registry_sysvar": FeatureCapability(
False, reason="registry_saved_variables_have_no_home_in_a_file"
),
"dwgprops": FeatureCapability(
False,
reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer",
),
},
)
@@ -6247,14 +6309,77 @@ class EzdxfBackend(AutoCADBackend):
async def system_get_variable(self, name) -> Any:
def _sync():
doc = self._require_doc()
return doc.header.get(f"${name.upper()}", None)
key = str(name).upper()
if key in _VPORT_SYSVARS:
attr, shape = _VPORT_SYSVARS[key]
raw = getattr(self._active_vport(doc).dxf, attr)
if shape == "point":
return (float(raw[0]), float(raw[1]))
return int(raw)
if key == "CANNOSCALE":
return self._dictionary_variable(doc, "CANNOSCALE", "1:1")
if key == "CANNOSCALEVALUE":
return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1"))
if key in _UNSAVED_SYSVARS:
return None # a file holds no value; the template's memory is not one
raw = doc.header.get(f"${key}", None)
if key in _HEADER_DEGREE_SYSVARS and raw is not None:
return math.radians(float(raw))
return raw
return await self._async(_sync)
async def system_set_variable(self, name, value) -> dict:
from ezdxf.lldxf.const import DXFKeyError
def _sync():
doc = self._require_doc()
doc.header[f"${name.upper()}"] = value
key = str(name).upper()
if key in _UNSAVED_SYSVARS:
raise UnsupportedCapabilityError(
"registry_sysvar",
f"system_set_variable: {key} is saved in the AutoCAD registry (or not "
"saved at all), never in the drawing, so the headless ezdxf backend has "
"nowhere to keep it. Set it on the live COM backend.",
)
if key in _VPORT_SYSVARS:
attr, shape = _VPORT_SYSVARS[key]
vport = self._active_vport(doc)
if shape == "point":
x, y = float(value[0]), float(value[1])
if x <= 0 or y <= 0:
raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}")
vport.dxf.set(attr, (x, y))
else:
vport.dxf.set(attr, 1 if int(value) else 0)
elif key == "CANNOSCALE":
self._set_annotation_scale(doc, value)
elif key == "CANNOSCALEVALUE":
raise ValueError(
"CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead."
)
else:
stored = value
if key in _HEADER_DEGREE_SYSVARS:
stored = math.degrees(float(value))
try:
doc.header[f"${key}"] = stored
except DXFKeyError as exc:
# Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py)
# while saying why the write is refused rather than dropped.
raise ValueError(
f"system_set_variable: {exc} ezdxf has no header slot for it, so the "
"value would be dropped at save; system_variable_describe(name) says "
"where the variable lives."
) from exc
if key in ("LIMMIN", "LIMMAX"):
# Measured: `Drawing.update_limits()` copies the model-space
# LAYOUT's limits into the header on every write(), so a
# header-only value is overwritten at save. The layout is
# the source of truth; keep both in step.
doc.modelspace().dxf_layout.dxf.set(
key.lower(), (float(value[0]), float(value[1]))
)
self._mark_dirty()
return {"ok": True, "variable": name, "value": value}
@@ -6788,3 +6913,247 @@ class EzdxfBackend(AutoCADBackend):
return _entity_info_dxf(xline)
return await self._async(_sync)
# ── settings (track E, group C) ───────────────────────────────────────────
#
# Where the variables the `drawing_settings` facade reaches actually live
# in a DXF file when they are not header variables.
@staticmethod
def _active_vport(doc):
"""The `*Active` VPORT entry — grid and snap state live on it.
Owned here (group C). Group V's named views use the same entry and
carry a worktree-only copy that the merge agent deletes; this
definition is the one that ships.
"""
entries = doc.viewports.get("*Active")
if not entries:
entries = [doc.viewports.new("*Active")]
return entries[0]
@staticmethod
def _variable_dictionary(doc, create: bool):
"""`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there)."""
vardict = doc.rootdict.get("AcDbVariableDictionary")
if vardict is None and create:
vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary")
return vardict
def _dictionary_variable(self, doc, key: str, default: str) -> str:
vardict = self._variable_dictionary(doc, create=False)
if vardict is None:
return default
entry = vardict.get(key)
return default if entry is None else str(entry.dxf.value)
@staticmethod
def _scale_entry_name(entry) -> str | None:
"""The name a SCALE object carries in its AcDbScale group 300.
The dictionary *key* is not the name: AutoCAD saves ACAD_SCALELIST
keyed `A0`, `A1`, … and only group 300 says `1:50`. ezdxf loads SCALE
as `DXFTagStorage`, so the tag is read from the stored subclass.
"""
xtags = getattr(entry, "xtags", None)
if xtags is None:
return None
for subclass in xtags.subclasses:
if subclass and subclass[0].code == 100 and subclass[0].value == "AcDbScale":
for tag in subclass:
if tag.code == 300:
return str(tag.value)
return None
# AutoCAD's default metric ACAD_SCALELIST, in its own order (measured:
# AutoCAD 2026, MEASUREMENT 1, `Documents.Add()` saved as DXF — 17 SCALE
# objects keyed A0 … A9, B0 … B6). An imperial seat (MEASUREMENT 0) adds the
# architectural fractions on top; those are not seeded — every ratio here
# is valid in either unit system, and AutoCAD's SCALELISTEDIT can add more.
_DEFAULT_SCALE_LIST: tuple[str, ...] = (
"1:1",
"1:2",
"1:4",
"1:5",
"1:8",
"1:10",
"1:16",
"1:20",
"1:30",
"1:40",
"1:50",
"1:100",
"2:1",
"4:1",
"8:1",
"10:1",
"100:1",
)
@staticmethod
def _scale_list_key(index: int) -> str:
"""The dictionary key AutoCAD gives the ``index``-th SCALE: A0 … A9, B0 …"""
return f"{chr(ord('A') + index // 10)}{index % 10}"
@classmethod
def _add_scale_object(cls, doc, scales, name: str, paper: float, drawing: float) -> None:
"""Author one SCALE object and file it under the next free AutoCAD-style key.
ezdxf has no SCALE entity class, so the object is authored as raw tags
and loaded through the factory (a `DXFTagStorage` that exports
verbatim). Measured: it round-trips, ezdxf registers the CLASS, and
`doc.audit()` is clean after reopen.
"""
from ezdxf.entities import factory
from ezdxf.lldxf.extendedtags import ExtendedTags
existing_keys = set(scales.keys())
index = 0
while cls._scale_list_key(index) in existing_keys:
index += 1
handle = doc.entitydb.next_handle()
unit = 1 if paper == drawing else 0
text = (
f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n"
f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n"
)
entry = factory.load(ExtendedTags.from_text(text), doc)
doc.entitydb.add(entry)
doc.objects.add_object(entry)
scales.add(cls._scale_list_key(index), entry)
@classmethod
def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool:
"""Add `name` to ACAD_SCALELIST unless present; True when added.
Presence is decided by the SCALE objects' group-300 names, never by
the dictionary key — an AutoCAD-authored drawing keys the list `A0`,
`A1`, … and only group 300 says `1:50`; matching on the key appended a
duplicate `1:50` to every one of them.
An ezdxf-authored drawing has **no** ACAD_SCALELIST entries at all.
AutoCAD repopulates its default list only when the dictionary is
empty, so appending the one requested scale to an empty list shipped a
file whose scale list *was* that one entry — measured (AutoCAD 2026):
`SetVariable("CANNOSCALE", "1:1")` then fails with `Error setting
system variable` and the annotation scale cannot be changed in the
UI. An empty list is therefore seeded with AutoCAD's own default list
first, so the saved file offers what a fresh AutoCAD drawing offers.
AutoCAD only honours a CANNOSCALE that names an entry of this list.
"""
scales = doc.rootdict.get("ACAD_SCALELIST")
if scales is None:
scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
if len(scales) == 0:
for default in cls._DEFAULT_SCALE_LIST:
_, default_paper, default_drawing = parse_scale(default)
cls._add_scale_object(doc, scales, default, default_paper, default_drawing)
for _key, entry in scales.items():
if cls._scale_entry_name(entry) == name:
return False
cls._add_scale_object(doc, scales, name, paper, drawing)
return True
def _set_annotation_scale(self, doc, value: Any) -> str:
name, paper, drawing = parse_scale(value) # refused before any write
if self._is_r12():
# Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf
# exports neither AcDbVariableDictionary/DICTIONARYVAR nor the
# SCALE objects. The in-memory write "succeeded" and reloaded as
# 1:1 -- the header-only-write-that-vanishes class again. Refused
# here, before either dictionary is created, so a refused call
# leaves the root dictionary as it found it.
raise ValueError(
"CANNOSCALE: R12 has no OBJECTS section, so the annotation scale "
"(AcDbVariableDictionary and ACAD_SCALELIST) would be lost on save. "
"Save as R2000 or newer first."
)
self._ensure_scale_entry(doc, name, paper, drawing)
vardict = self._variable_dictionary(doc, create=True)
entry = vardict.get("CANNOSCALE")
if entry is None:
vardict.add_dict_var("CANNOSCALE", name)
else:
entry.dxf.value = name
return name
async def drawing_properties_get(self) -> dict:
def _sync():
doc = self._require_doc()
return {
"summary": {field: None for field in SUMMARY_FIELDS},
"summary_available": False,
"custom": {tag: value for tag, value in doc.header.custom_vars},
"backend": "ezdxf",
}
return await self._async(_sync)
async def drawing_properties_set(
self, summary: dict | None = None, custom: dict | None = None
) -> dict:
written, to_write, to_delete = validate_drawing_properties(summary, custom)
if written:
# The five summary fields live in the DWG SummaryInfo stream, which
# a DXF has no slot for; refuse before the custom keys are touched.
raise UnsupportedCapabilityError(
"dwgprops",
"drawing_properties_set: the summary fields (title, subject, author, keywords, "
"comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot "
f"write (asked for {sorted(written)}). Custom properties work on both engines; "
"set the summary on the live COM backend.",
)
def _sync():
doc = self._require_doc()
if (to_write or to_delete) and doc.dxfversion < "AC1018":
# Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only
# after $LASTSAVEDBY, a header variable that does not exist
# before R2004 (its own CustomVars docstring says so). On an
# R12 or R2000 document the in-memory write "succeeded",
# drawing_properties_get echoed it back, and drawing_save_as
# (version kept) wrote nothing -- the header-only write that
# vanishes on save, the class f3ff703 removed for CANNOSCALE.
# Refused here, before custom_vars is touched.
raise ValueError(
f"custom properties: this document is DXF {doc.dxfversion} "
f"({doc.acad_release}), and $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY "
"header pairs are only written for R2004 (AC1018) or newer, so "
f"{sorted(to_write) + sorted(to_delete)} would be lost on save. "
"Save as R2004 or newer first."
)
custom_vars = doc.header.custom_vars
# AutoCAD compares custom keys with a simple per-character case
# compare (measured on 2026: SetCustomByKey("PROJECT") updates an
# existing "Project" and keeps that spelling, a second AddCustomInfo
# is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys,
# which str.casefold() would merge), and ezdxf's CustomVars are
# plain case-sensitive tuples, so the match is done here with
# custom_key_fold: an existing tag is updated in place under its
# stored spelling, never duplicated under a second one.
props = custom_vars.properties
for key, value in to_write.items():
folded = custom_key_fold(key)
hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded]
if hits:
props[hits[0]] = (props[hits[0]][0], value)
else:
custom_vars.append(key, value)
deleted = []
for key in to_delete:
folded = custom_key_fold(key)
kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded]
if len(kept) != len(props):
props[:] = kept
deleted.append(key)
if to_write or deleted:
self._mark_dirty()
return {
"ok": True,
"summary_written": [],
"custom_written": sorted(to_write),
"custom_deleted": deleted,
"backend": "ezdxf",
}
return await self._async(_sync)