diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 0362e35..d5b1b3b 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -515,6 +515,49 @@ 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)"} + + +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(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.""" + 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") + + def validate_drawing_properties( summary: dict | None, custom: dict | None ) -> tuple[dict[str, str], dict[str, str], list[str]]: @@ -526,6 +569,20 @@ def validate_drawing_properties( 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 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 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}") @@ -545,9 +602,11 @@ def validate_drawing_properties( 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) if value is None: to_delete.append(key) elif isinstance(value, str): + _check_custom_text(key, value, "value") to_write[key] = value else: raise TypeError( diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index b33eb18..5b30d66 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1117,7 +1117,7 @@ class EzdxfBackend(AutoCADBackend): ), "dwgprops": FeatureCapability( False, - reason="summary_fields_need_live_autocad;custom_properties_supported", + reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer", ), }, ) @@ -6125,6 +6125,22 @@ class EzdxfBackend(AutoCADBackend): 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 for key, value in to_write.items(): if custom_vars.has_tag(key): diff --git a/server.py b/server.py index 9b11afa..591bc2b 100644 --- a/server.py +++ b/server.py @@ -6962,8 +6962,14 @@ 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. On the live engine a custom key is added - with AddCustomInfo when new and changed with SetCustomByKey when present. + `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 + when new and changed with SetCustomByKey when present. """ summary = { "title": title, diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index afcc3ec..aaea9df 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -4,7 +4,15 @@ Headlessly the five summary fields live in the DWG SummaryInfo stream, which ezdxf cannot write, so any non-null summary field is refused with `capability: dwgprops` before anything is touched; custom properties are `$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both -engines (measured: they survive save + reopen). +engines (measured: they survive save + reopen on R2004 and newer; ezdxf +only emits the pairs for AC1018+, so an older document is refused headlessly +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. """ from __future__ import annotations @@ -42,6 +50,19 @@ def test_validation_splits_writes_and_deletes(): (None, {"": "x"}, TypeError, "non-empty"), ("Gearbox", None, TypeError, "summary"), (None, ["PROJECT"], TypeError, "custom"), + # Keys AutoCAD's AddCustomInfo rejects as 'Invalid key' (measured on + # AutoCAD 2026) -- refused here so the live engine never fails + # mid-write after the summary and earlier keys were already applied. + (None, {" BAD ": "x"}, ValueError, "' BAD '"), + (None, {"PROJECT ": "x"}, ValueError, "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"), + # 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"), + (None, {"K\nEY": "x"}, ValueError, "the key contains a line break"), + (None, {"K\nEY": None}, ValueError, "the key contains a line break"), ], ) def test_validation_refuses_by_name(summary, custom, exc, fragment): @@ -50,6 +71,13 @@ 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]) +def test_validation_accepts_keys_autocad_accepts(key): + """Measured: internal spaces, tabs, unicode and 300-char keys pass + AddCustomInfo; the validator must not be stricter than AutoCAD.""" + assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, []) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -85,6 +113,96 @@ async def test_custom_properties_write_replace_delete_and_survive_reopen(backend assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} +async def test_line_break_is_refused_before_any_write_and_the_file_still_opens(backend, tmp_path): + """Measured: `custom={'NOTE': 'line1\\nline2', 'AFTER': 'ok'}` reported + `ok: True`, and the saved DXF failed to reopen with + `DXFStructureError: Invalid group code "line2"` -- the whole drawing lost, + reported as success. The request is refused as a whole, so AFTER is not + written either, and the file saved afterwards reopens.""" + with pytest.raises(ValueError, match=r"custom\['NOTE'\]: the value contains a line break"): + await backend.drawing_properties_set(custom={"NOTE": "line1\nline2", "AFTER": "ok"}) + assert (await backend.drawing_properties_get())["custom"] == {} + path = str(tmp_path / "clean.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def test_autocad_invalid_keys_are_refused_headlessly_too(backend): + """The headless engine used to write ' BAD ', 'A=B' and 'A;B' without + complaint (and AutoCAD loaded them from the DXF) while the live engine + half-applied the same call; both now refuse before writing.""" + with pytest.raises(ValueError, match="' BAD '"): + await backend.drawing_properties_set(custom={"AAA": "first", " BAD ": "x", "ZZZ": "n"}) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def _backend_for_version(tmp_path, version: str): + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + path = tmp_path / f"{version}.dxf" + ezdxf.new(version).saveas(str(path)) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_open(str(path)) + return backend + + +@pytest.mark.parametrize("version, dxfversion", [("R12", "AC1009"), ("R2000", "AC1015")]) +async def test_custom_properties_before_r2004_are_refused_rather_than_lost( + tmp_path, version, dxfversion +): + """Measured: on an R12 or R2000 document `drawing_properties_set` reported + `ok: True, custom_written: ['PROJECT']`, `drawing_properties_get` echoed + it from memory, and `drawing_save_as` (version kept) wrote nothing -- + ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY, + which does not exist before R2004. The header-only write that vanishes on + save, the class f3ff703 removed for CANNOSCALE. Refused up front, before + custom_vars is touched; the reopened file is read back to prove it.""" + backend = await _backend_for_version(tmp_path, version) + try: + assert backend._doc.dxfversion == dxfversion + with pytest.raises(ValueError, match=dxfversion) as excinfo: + await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"}) + assert "R2004 or newer" in str(excinfo.value) + assert "PROJECT" in str(excinfo.value) + assert list(backend._doc.header.custom_vars) == [] + assert (await backend.drawing_properties_get())["custom"] == {} + # A delete on the same document is refused for the same reason: it + # would report a key removed that the file could never have held. + with pytest.raises(ValueError, match="R2004 or newer"): + await backend.drawing_properties_set(custom={"PROJECT": None}) + # An empty request touches nothing and is not a write to refuse. + res = await backend.drawing_properties_set(custom={}) + assert res["ok"] is True and res["custom_written"] == [] + + out = tmp_path / f"{version}_out.dxf" + await backend.drawing_save_as(str(out)) + await backend.drawing_open(str(out)) + assert backend._doc.dxfversion == dxfversion + assert (await backend.drawing_properties_get())["custom"] == {} + finally: + await backend.disconnect() + + +async def test_custom_properties_on_r2004_survive_save_and_reopen(tmp_path): + """The oldest version that carries the pairs -- the boundary the refusal + sits on -- round-trips.""" + backend = await _backend_for_version(tmp_path, "R2004") + try: + res = await backend.drawing_properties_set(custom={"PROJECT": "X-1"}) + assert res["custom_written"] == ["PROJECT"] + out = tmp_path / "r2004_out.dxf" + await backend.drawing_save_as(str(out)) + await backend.drawing_open(str(out)) + assert backend._doc.dxfversion == "AC1018" + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-1"} + finally: + await backend.disconnect() + + async def test_summary_is_refused_headlessly_before_any_write(backend): with pytest.raises(UnsupportedCapabilityError) as excinfo: await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"}) @@ -231,3 +349,19 @@ async def test_com_validates_before_touching_activex(com_backend): with pytest.raises(ValueError): await backend.drawing_properties_set(summary={"edition": "1"}) assert info.calls == [] and info.Title == "Old title" + + +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 + COM error from AddCustomInfo(' BAD ') and left subject and AAA written, + ZZZ absent -- a half-applied write reported as an error. The shared + validator now names the key and nothing reaches SummaryInfo.""" + backend, info = com_backend + with pytest.raises(ValueError, match="' BAD '"): + await backend.drawing_properties_set( + summary={"subject": "PARTIAL?"}, + custom={"AAA": "first", " BAD ": "x", "ZZZ": "never"}, + ) + assert info.calls == [] and info.Subject == "" + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]