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
This commit is contained in:
Umut CAN
2026-09-21 22:04:22 +03:00
co-authored by Claude Opus 5
parent 4fe2d963c0
commit dda11755cf
5 changed files with 297 additions and 55 deletions
+47 -14
View File
@@ -199,6 +199,27 @@ _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.
@@ -4293,31 +4314,43 @@ class ComBackend(AutoCADBackend):
def _sync():
info = _acad_doc().SummaryInfo
# AutoCAD's custom keys are case-insensitive (measured on 2026:
# AddCustomInfo("PROJECT") over an existing "Project" raises
# 'Duplicate key'; SetCustomByKey / RemoveCustomByKey match either
# spelling and the stored spelling is kept), so existence is
# checked casefolded — a case-sensitive check routed "PROJECT" to
# AddCustomInfo and failed mid-write after the summary fields and
# the earlier keys were already applied.
# 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).casefold())
existing.add(str(key))
for field, value in written.items():
setattr(info, _SUMMARY_ATTRS[field], value)
for key, value in to_write.items():
# AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey
# on a missing one does too — route by what is really there.
if key.casefold() in existing:
if key in existing:
info.SetCustomByKey(key, value)
else:
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:
if key.casefold() in existing:
try:
info.RemoveCustomByKey(key)
deleted.append(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),
+47 -8
View File
@@ -525,6 +525,43 @@ SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
_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.
@@ -593,11 +630,12 @@ def validate_drawing_properties(
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 differ only by case — AutoCAD's custom keys
are case-insensitive (measured: ``AddCustomInfo("PROJECT")`` over an
existing ``Project`` raises ``Duplicate key``; ``SetCustomByKey`` /
``RemoveCustomByKey`` match either spelling), so the second would fail
mid-write on the live engine and write a second tag headlessly;
* 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``),
@@ -618,16 +656,17 @@ def validate_drawing_properties(
raise TypeError("custom: must be an object of {key: text | null}")
to_write: dict[str, str] = {}
to_delete: list[str] = []
seen: dict[str, str] = {} # casefolded key -> the spelling seen first
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 = key.casefold()
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's custom keys are case-insensitive (AddCustomInfo raises "
"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
+12 -9
View File
@@ -41,6 +41,7 @@ from .base import (
)
from .contracts.settings import (
SUMMARY_FIELDS,
custom_key_fold,
parse_scale,
scale_value,
validate_drawing_properties,
@@ -6142,24 +6143,26 @@ class EzdxfBackend(AutoCADBackend):
"Save as R2004 or newer first."
)
custom_vars = doc.header.custom_vars
# AutoCAD's custom keys are case-insensitive (measured on 2026:
# SetCustomByKey("PROJECT") updates an existing "Project" and keeps
# that spelling; a second AddCustomInfo is 'Duplicate key'), and
# ezdxf's CustomVars are plain case-sensitive tuples, so the match
# is done here: an existing tag is updated in place under its
# 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 = key.casefold()
hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded]
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 = key.casefold()
kept = [(tag, v) for tag, v in props if tag.casefold() != folded]
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)