diff --git a/backends/com_backend.py b/backends/com_backend.py index b72d124..7cbc75a 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -4293,22 +4293,29 @@ 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. existing: set[str] = set() for index in range(int(info.NumCustomInfo())): key, _value = info.GetCustomByIndex(index) - existing.add(str(key)) + existing.add(str(key).casefold()) 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 in existing: + if key.casefold() in existing: info.SetCustomByKey(key, value) else: info.AddCustomInfo(key, value) deleted = [] for key in to_delete: - if key in existing: + if key.casefold() in existing: info.RemoveCustomByKey(key) deleted.append(key) return { diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index d5b1b3b..0321cdd 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -515,10 +515,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") -#: Characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects inside a custom -#: key with ``Invalid key`` (measured on AutoCAD 2026; internal spaces, tabs -#: and other unicode are accepted). ``\xa0`` is the no-break space. -_CUSTOM_KEY_FORBIDDEN = {"=": "'='", ";": "';'", "\xa0": "a no-break space (U+00A0)"} +#: 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 _check_custom_text(key: str, text: str, what: str) -> None: @@ -538,24 +542,30 @@ def _check_custom_text(key: str, text: str, what: str) -> None: ) -def _check_custom_key(key: str) -> None: - """Mirror AutoCAD's own custom-key rules so the live engine cannot refuse - mid-write. Measured: ``AddCustomInfo`` raises ``Invalid key`` for a key - with leading/trailing whitespace and for ``=``, ``;`` or a no-break space - anywhere in it — after the summary fields and earlier keys were already - applied — while the headless engine happily writes the same key.""" +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." ) - for char, name in _CUSTOM_KEY_FORBIDDEN.items(): - if char in key: - raise ValueError( - f"custom: key {key!r} contains {name} — AutoCAD's AddCustomInfo rejects " - "it as 'Invalid key'. Use another separator." - ) - _check_custom_text(key, key, "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( @@ -574,11 +584,20 @@ def validate_drawing_properties( 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 with leading or trailing whitespace, or containing ``=``, ``;`` or - a no-break space — AutoCAD's ``AddCustomInfo`` rejects these as - ``Invalid key`` mid-write (measured on AutoCAD 2026), 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 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 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; * 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``), @@ -599,13 +618,23 @@ 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 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_key(key) + _check_custom_text(key, key, "key") + folded = key.casefold() + 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 " + "'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: diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 5b30d66..de1c5b7 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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() diff --git a/server.py b/server.py index 591bc2b..de11684 100644 --- a/server.py +++ b/server.py @@ -6962,13 +6962,20 @@ async def drawing_properties_set( engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has no slot for it; custom properties still work headlessly), a non-string value (`TypeError` naming the field or key — nothing is coerced with - `str()`), an empty custom key, a custom key AutoCAD's AddCustomInfo would - reject mid-write (leading/trailing whitespace, `=`, `;`, no-break space — - `ValueError` naming the key, on both engines), a line break in a custom - key or value (it corrupts the DXF on save), and headlessly a document - older than R2004 (`ValueError` — ezdxf only writes the custom-property - header pairs for AC1018+, so they would vanish at save; save as R2004 or - newer first). On the live engine a custom key is added with AddCustomInfo + `str()`), an empty custom key, a custom key to *write* that AutoCAD's + AddCustomInfo would reject mid-write (measured on AutoCAD 2026: leading or + trailing whitespace, or any of the thirteen characters + `" * , / : ; < = > ? \\ ` |` anywhere in the key; internal spaces, tabs + and unicode are fine — `ValueError` naming the key, on both engines; a + delete is exempt because RemoveCustomByKey never validates syntax, so a + key that reached the drawing another way stays removable), two keys in + one request that differ only by case (custom keys are case-insensitive in + AutoCAD), a line break in a custom key or value (it corrupts the DXF on + save), and headlessly a document older than R2004 (`ValueError` — ezdxf + only writes the custom-property header pairs for AC1018+, so they would + vanish at save; save as R2004 or newer first). A key is matched to the + drawing case-insensitively on both engines and keeps its stored spelling + when updated. On the live engine a custom key is added with AddCustomInfo when new and changed with SetCustomByKey when present. """ summary = { diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index aaea9df..92e3bbb 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -10,9 +10,14 @@ instead of losing the write at save). Custom keys and values are validated once, by name, against what would break *either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as -`Invalid key` (measured on AutoCAD 2026: leading/trailing whitespace, `=`, -`;`, no-break space) and line breaks, which ezdxf writes unescaped and -which corrupt the whole DXF. +`Invalid key` (measured on AutoCAD 2026 by sweeping every printable ASCII +character through a scratch document: leading/trailing whitespace and exactly +the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key -- +internal spaces, tabs, control characters, a no-break space and unicode are +accepted), case-variant duplicates (AutoCAD's keys are case-insensitive: +`Duplicate key`), and line breaks, which ezdxf writes unescaped and which +corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules +(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`). """ from __future__ import annotations @@ -22,7 +27,11 @@ import types import pytest from backends.base import UnsupportedCapabilityError -from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties +from backends.contracts.settings import ( + _CUSTOM_KEY_FORBIDDEN, + SUMMARY_FIELDS, + validate_drawing_properties, +) pytestmark = pytest.mark.asyncio @@ -55,9 +64,15 @@ def test_validation_splits_writes_and_deletes(): # mid-write after the summary and earlier keys were already applied. (None, {" BAD ": "x"}, ValueError, "' BAD '"), (None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"), + (None, {"\tTAB": "x"}, ValueError, "leading or trailing whitespace"), + (None, {"NB\xa0": "x"}, ValueError, "leading or trailing whitespace"), (None, {"A=B": "x"}, ValueError, "'A=B' contains '='"), (None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"), - (None, {"NB\xa0SP": "x"}, ValueError, "no-break space"), + (None, {"A:B/C": "x"}, ValueError, "'A:B/C' contains '/' ':'"), + # Case-variant duplicates: AutoCAD raises 'Duplicate key' on the second. + (None, {"Project": "a", "PROJECT": "b"}, ValueError, "differ only by case"), + (None, {"Project": "a", "PROJECT": None}, ValueError, "differ only by case"), + (None, {"Gr\u00fcn": "a", "GR\u00dcN": "b"}, ValueError, "differ only by case"), # A line break is written unescaped by ezdxf and corrupts the DXF. (None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"), (None, {"NOTE": "line1\rline2"}, ValueError, "line break"), @@ -71,13 +86,56 @@ def test_validation_refuses_by_name(summary, custom, exc, fragment): assert fragment in str(excinfo.value) -@pytest.mark.parametrize("key", ["IN SP", "T\tAB", "\u00dcn\u00efcode", "X" * 300]) +INVALID_KEY_CHARS = '"*,/:;<=>?\\`|' + + +def test_forbidden_set_is_exactly_the_thirteen_measured_characters(): + assert _CUSTOM_KEY_FORBIDDEN == frozenset(INVALID_KEY_CHARS) + assert len(_CUSTOM_KEY_FORBIDDEN) == 13 + + +@pytest.mark.parametrize("char", sorted(INVALID_KEY_CHARS)) +def test_validation_refuses_every_addcustominfo_invalid_key_character(char): + """Measured on AutoCAD 2026 (`AddCustomInfo(f"A{c}B", "v")` for every + c in 0x20-0x7E on a scratch document): exactly these thirteen raise + `Invalid key`. The earlier fix mirrored only `=` and `;`, so 'A:B', + 'REV/2', 'SIZE, mm', 'A"B' ... still half-applied on the live engine.""" + key = f"A{char}B" + with pytest.raises(ValueError, match="Invalid key") as excinfo: + validate_drawing_properties(None, {key: "x"}) + assert repr(key) in str(excinfo.value) and repr(char) in str(excinfo.value) + + +@pytest.mark.parametrize( + "key", + [ + "IN SP", + "T\tAB", + "\u00dcn\u00efcode", + "X" * 300, + "NB\xa0SP", + "A\x01B", + "A\x1fB", + "A!#$%&'()+-.@[]^_{}~B", + "\u4e2d\u2014\u200b", + ], +) def test_validation_accepts_keys_autocad_accepts(key): - """Measured: internal spaces, tabs, unicode and 300-char keys pass + """Measured: internal spaces, tabs, control characters, a no-break space, + unicode, every other printable ASCII character and 300-char keys pass AddCustomInfo; the validator must not be stricter than AutoCAD.""" assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, []) +@pytest.mark.parametrize("key", ["A=B", " PAD ", "A:B", "NB\xa0", 'A"B']) +def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key): + """A delete never calls AddCustomInfo, and RemoveCustomByKey does not + validate syntax (measured: 'Key not found', never 'Invalid key'), so a key + a DXF script wrote must stay removable. The line-break rule still holds + for both directions (a delete of such a key is pinned refused above).""" + assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key]) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -137,6 +195,60 @@ async def test_autocad_invalid_keys_are_refused_headlessly_too(backend): assert (await backend.drawing_properties_get())["custom"] == {} +async def test_legacy_invalid_syntax_keys_stay_deletable_headlessly(tmp_path): + """Regression of 2b9791f: `_check_custom_key` ran before the `value is + None` branch, so `{'A=B': None}` on a document carrying 'A=B' (any ezdxf + script writes such keys; AutoCAD loads them from the DXF) was refused with + a reason that only applies to AddCustomInfo -- the tool showed a key it + could never remove.""" + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + path = tmp_path / "legacy.dxf" + doc = ezdxf.new("R2010") + for tag in ("A=B", "NB\xa0SP", " PAD ", "A:B"): + doc.header.custom_vars.append(tag, "legacy") + doc.saveas(str(path)) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_open(str(path)) + assert (await backend.drawing_properties_get())["custom"] == { + "A=B": "legacy", + "NB\xa0SP": "legacy", + " PAD ": "legacy", + "A:B": "legacy", + } + res = await backend.drawing_properties_set(custom={"A=B": None, " PAD ": None}) + assert res["custom_deleted"] == ["A=B", " PAD "] + assert (await backend.drawing_properties_get())["custom"] == { + "NB\xa0SP": "legacy", + "A:B": "legacy", + } + # ...but writing such a key is still refused, before anything is touched. + with pytest.raises(ValueError, match="'A:B' contains ':'"): + await backend.drawing_properties_set(custom={"A:B": "new", "OK": "x"}) + assert (await backend.drawing_properties_get())["custom"] == { + "NB\xa0SP": "legacy", + "A:B": "legacy", + } + finally: + await backend.disconnect() + + +async def test_custom_keys_match_case_insensitively_headlessly(backend): + """Mirrors AutoCAD (measured on 2026): a write to 'PROJECT' over an + existing 'Project' updates it under the stored spelling instead of adding + a second tag AutoCAD would call a duplicate; a delete matches either + spelling.""" + await backend.drawing_properties_set(custom={"Project": "X-1", "Rev": "A"}) + res = await backend.drawing_properties_set(custom={"PROJECT": "X-2", "rev": None}) + assert res["custom_written"] == ["PROJECT"] and res["custom_deleted"] == ["rev"] + assert list(backend._doc.header.custom_vars) == [("Project", "X-2")] + assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"} + + async def _backend_for_version(tmp_path, version: str): import ezdxf @@ -274,17 +386,35 @@ class _FakeSummaryInfo: self.calls.append(("GetCustomByIndex", index)) return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple + # Measured on AutoCAD 2026: the three mutators match keys case-insensitively, + # an existing key keeps its stored spelling, a second Add is 'Duplicate key', + # a Set/Remove of an absent key is 'Key not found'. + + def _index(self, key): + for i, (k, _v) in enumerate(self._custom): + if k.casefold() == key.casefold(): + return i + return None + def AddCustomInfo(self, key, value): self.calls.append(("AddCustomInfo", key, value)) + if self._index(key) is not None: + raise RuntimeError("AutoCAD COM error: Duplicate key") self._custom.append((key, value)) def SetCustomByKey(self, key, value): self.calls.append(("SetCustomByKey", key, value)) - self._custom = [(k, value if k == key else v) for k, v in self._custom] + i = self._index(key) + if i is None: + raise RuntimeError("AutoCAD COM error: Key not found") + self._custom[i] = (self._custom[i][0], value) def RemoveCustomByKey(self, key): self.calls.append(("RemoveCustomByKey", key)) - self._custom = [(k, v) for k, v in self._custom if k != key] + i = self._index(key) + if i is None: + raise RuntimeError("AutoCAD COM error: Key not found") + del self._custom[i] @pytest.fixture @@ -351,6 +481,53 @@ async def test_com_validates_before_touching_activex(com_backend): assert info.calls == [] and info.Title == "Old title" +@pytest.mark.parametrize( + "key", ["A:B", "REV/2", "SIZE, mm", 'A"B', "A\\B", "A", "A|B", "A*B", "A?B"] +) +async def test_com_refuses_every_addcustominfo_invalid_key_before_any_write(com_backend, key): + """Re-measured on AutoCAD 2026 after 2b9791f: `summary={'subject': + 'PARTIAL?'}, custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still + raised from AddCustomInfo('A:B') and left subject='PARTIAL?', + custom={'AAA': 'first'} -- the same half-write for every character the + first fix did not mirror. Nothing reaches SummaryInfo now.""" + backend, info = com_backend + with pytest.raises(ValueError, match="Invalid key"): + await backend.drawing_properties_set( + summary={"subject": "PARTIAL?"}, + custom={"AAA": "first", key: "x", "ZZZ": "never"}, + ) + assert info.calls == [] and info.Subject == "" + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] + + +async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend): + """RemoveCustomByKey does not validate syntax (measured: 'Key not found' + for an absent 'A=B', never 'Invalid key'), so a key AutoCAD loaded from a + DXF is removed, not refused.""" + backend, info = com_backend + info._custom.append(("A=B", "legacy")) + res = await backend.drawing_properties_set(custom={"A=B": None}) + assert res["custom_deleted"] == ["A=B"] + assert ("RemoveCustomByKey", "A=B") in info.calls + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] + + +async def test_com_routes_case_variant_keys_to_set_and_remove(com_backend): + """Measured on AutoCAD 2026: AddCustomInfo('PROJECT') over an existing + 'Project' raises 'Duplicate key' -- a case-sensitive existence check + routed it there and failed mid-write. Existence is matched casefolded.""" + backend, info = com_backend + res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"}) + assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"] + mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] + assert mutations == [ + ("SetCustomByKey", "project", "X-2"), + ("AddCustomInfo", "New", "n"), + ("RemoveCustomByKey", "old"), + ] + assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept" + + async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed