fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively

The 2b9791f fix mirrored only the characters the first review happened to
measure ('=' and ';'), so `custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}`
still raised from AddCustomInfo('A:B') on the live engine after subject and
AAA were applied -- the same half-write for 11 other keys. Re-measured on
AutoCAD 2026 by sweeping every character 0x20-0x7E through a scratch
document: exactly thirteen printable characters are 'Invalid key' anywhere
in a key (" * , / : ; < = > ? \ ` |); control characters, internal spaces,
a no-break space and unicode are accepted; leading/trailing whitespace
(space, tab, NBSP) is refused. `_CUSTOM_KEY_FORBIDDEN` is now that set,
the internal-NBSP refusal (wrong: measured accepted) is gone, and the
validator / tool docstrings state the measured rule instead of a closed
enumeration that was false.

A delete (`{key: None}`) is exempt from the AddCustomInfo syntax rules:
RemoveCustomByKey never validates syntax (measured: 'Key not found', never
'Invalid key'), and 2b9791f ran the check before the None branch, so a key
an ezdxf script wrote ('A=B', ' PAD ') showed in drawing_properties_get and
could never be removed. The line-break rule still applies both ways.

Also measured: custom keys are case-insensitive in AutoCAD (AddCustomInfo
over an existing case-variant is 'Duplicate key'; SetCustomByKey and
RemoveCustomByKey match either spelling and keep the stored one). The COM
routing was case-sensitive, so `{'PROJECT': ...}` over 'Project' went to
AddCustomInfo and failed mid-write -- the same class. Both engines now
match existing keys casefolded (update in place under the stored spelling,
never a second tag), and a request naming two case-variants is refused.

Tests: parametrized validator sweep over all thirteen characters, the
accepted set (control chars, NBSP, unicode, the other printables), the
delete exemption headlessly on a DXF carrying such keys and on the fake
SummaryInfo, case-variant refusal, and case-insensitive routing against a
fake that raises 'Duplicate key' / 'Key not found' like AutoCAD. Confirmed
live through the real ComBackend on a scratch document (Close(False)).

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 21:37:44 +03:00
co-authored by Claude Opus 5
parent 2b9791fbcd
commit 4fe2d963c0
5 changed files with 277 additions and 46 deletions
+15 -4
View File
@@ -6142,15 +6142,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
# stored spelling, never duplicated under a second one.
props = custom_vars.properties
for key, value in to_write.items():
if custom_vars.has_tag(key):
custom_vars.replace(key, value)
folded = key.casefold()
hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded]
if hits:
props[hits[0]] = (props[hits[0]][0], value)
else:
custom_vars.append(key, value)
deleted = []
for key in to_delete:
if custom_vars.has_tag(key):
custom_vars.remove(key, all=True)
folded = key.casefold()
kept = [(tag, v) for tag, v in props if tag.casefold() != folded]
if len(kept) != len(props):
props[:] = kept
deleted.append(key)
if to_write or deleted:
self._mark_dirty()