Files
autocad-mcp-pro/backends/contracts/settings.py
T
Umut CANandClaude Opus 5 dda11755cf fix(task-16): custom keys follow AutoCAD's simple case compare, and on COM AutoCAD routes the write
4fe2d96 matched custom keys with str.casefold(), but AutoCAD's key compare
is a simple one-to-one case compare, not Unicode full case folding.
Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the
first on a scratch document, Close(False)): Project/PROJECT, Grün/GRÜN,
é/É, ÿ/Ÿ, ł/Ł, σ/Σ, я/Я and dž/DŽ are one key ('Duplicate key'), while
Straße/STRASSE, ẞ/ß, kelvin/Kelvin-sign, µ/Μ, σς/ΣΣ and Dž/DŽ are two keys
(both stored) -- every one of those six is casefold()-equal. So with
'Straße' present, `custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': ...}`
was routed to SetCustomByKey, AutoCAD answered 'Key not found', and the
request raised with subject and BEFORE applied and AFTER never written --
the half-write class the fix claimed to close; a delete of 'STRASSE' over
'Straße' raised the same way; headlessly 'STRASSE' overwrote the 'Straße'
value instead of becoming a second tag; and the validator refused
{'Straße', 'STRASSE'} with a reason AutoCAD contradicts.

COM no longer routes from a Python approximation. An exact spelling goes
to SetCustomByKey; anything else is AddCustomInfo, and its 'Duplicate key'
(excepinfo scode -2145386475, measured) falls back to SetCustomByKey. A
delete is RemoveCustomByKey, and its 'Key not found' (scode -2145386476)
is reported as not deleted. Any other com_error keeps propagating. The
scode is matched before the English description because the HRESULT text
is localised.

The headless engine and the validator match on `custom_key_fold`: a
per-character fold to lowercase only when lower()/upper() are single
characters that round-trip and the character is one of the two; it never
changes string length and reproduces every measured pair (ß, ς, µ, the
Kelvin sign, Dž, ı and İ stay as they are). Docstrings in settings.py and
server.py now state that rule instead of 'case-insensitive'.

Tests: parametrized fold over the measured SAME / DISTINCT table (plus a
pin that casefold() merges the six DISTINCT pairs, which is why 4fe2d96
regressed), validator acceptance of each DISTINCT pair, headless
side-by-side storage and deletion of each pair, the fake SummaryInfo now
matches by the same fold and raises pywintypes.com_error with the
measured excepinfo tuples, the COM half-write scenario for each pair
(no SetCustomByKey guess, 'Key not found' delete tolerated, rest of the
request lands), the 'Duplicate key' fallback, and an unrelated
SummaryInfo failure still raising. The scodes were measured live before
the change; AutoCAD stopped answering COM (blocked in another agent's
command or a modal dialog) before the post-change re-run, so that run
is recorded as not done.

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

685 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Friendly facade over system variables.
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
class SettingsContract:
# A user-facing wrapper over system_get_variable / system_set_variable that
# maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
# Concrete + backend-agnostic: both engines accept the bare sysvar name, so
# the same call reads/writes on live COM and headless ezdxf alike.
async def drawing_settings(self, settings: dict | None = None) -> dict:
"""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, 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, limits, grid, grid_spacing, snap, snap_spacing,
ortho, polar, polar_angle, psltscale, annotation_scale, linear_units,
angular_units, dimstyle, textstyle.
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, spec in _SETTING_MAP.items():
try:
snapshot[key] = await self._read_setting(key, spec)
except Exception as exc:
snapshot[key] = {"error": str(exc)}
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
try:
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, "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
# ---------------------------------------------------------------------------
# Friendly setting name -> (AutoCAD system variable, value kind).
_SETTING_MAP: dict[str, tuple[str, str]] = {
"units": ("INSUNITS", "units"),
"linear_precision": ("LUPREC", "int"),
"angular_precision": ("AUPREC", "int"),
"ltscale": ("LTSCALE", "float"),
"dimscale": ("DIMSCALE", "float"),
# The dimension variables had no friendly name until v1.5.2, so the only
# way to reach dimension lettering was a raw `system_set_variable` — which
# is precisely the path that silently did nothing on the headless engine.
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
# never had anything to do with the numbers on a dimension.
"dim_text_height": ("DIMTXT", "float"),
"dim_arrow_size": ("DIMASZ", "float"),
"dim_decimals": ("DIMDEC", "int"),
"decimal_separator": ("DIMDSEP", "char"),
"zero_suppression": ("DIMZIN", "int"),
"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,
"inch": 1,
"inches": 1,
"in": 1,
"feet": 2,
"ft": 2,
"foot": 2,
"mm": 4,
"millimeter": 4,
"millimeters": 4,
"cm": 5,
"centimeter": 5,
"centimeters": 5,
"m": 6,
"meter": 6,
"meters": 6,
}
_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
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
#: 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."""
if kind == "units":
if isinstance(value, (int, float)) and not isinstance(value, bool):
return int(value)
code = _UNIT_TO_CODE.get(str(value).strip().lower())
if code is None:
raise ValueError(
f"units: unknown value {value!r}. Use one of "
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 = ""
elif isinstance(value, (int, float)):
code = int(value)
marker = chr(code) if 0 < code < 0x110000 else ""
else:
marker = str(value).strip()
if marker not in _DECIMAL_MARKERS:
raise ValueError(
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
)
return ord(marker)
if kind == "int":
return _in_range(key, int(float(value)))
if kind == "float":
return _in_range(key, float(value))
return value
#: AutoCAD's own ranges for the settings that now reach the renderer.
#:
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
#: header. Folding it into the per-dimension override is what gave it teeth:
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
#: with `ValueError: Format specifier missing precision` raised from inside
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
#: printed forty digits of float noise onto the sheet; and a non-positive
#: length was reported applied and silently discarded. `applied` has to mean
#: applied.
_SETTING_RANGES: dict[str, tuple[float, float]] = {
"dim_decimals": (0, 8), # DIMDEC
"zero_suppression": (0, 15), # DIMZIN is a bitmask
"linear_precision": (0, 8), # LUPREC
"angular_precision": (0, 8), # AUPREC
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
"text_size": (1e-9, 1e6), # TEXTSIZE
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
"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
}
def _in_range(key: str, value: float) -> float:
"""Refuse a value the renderer cannot use, naming the range."""
bounds = _SETTING_RANGES.get(key)
if bounds is None:
return value
low, high = bounds
if not low <= value <= high:
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
raise ValueError(
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
)
return value
def _decode_setting(key: str, kind: str, raw: Any) -> Any:
"""Present a raw system-variable value in friendly form."""
if raw is None:
return None
if kind == "units":
try:
code = int(raw)
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)
except (TypeError, ValueError):
return raw
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
# so report the marker that would actually be drawn.
return "," if code == 0 else chr(code)
if kind == "int":
try:
return int(raw)
except (TypeError, ValueError):
return raw
if kind == "float":
try:
return float(raw)
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