fix(task-16): custom properties refuse pre-R2004 headlessly, line breaks, and AutoCAD's invalid keys before any write
Three header-only-write / half-write classes in drawing_properties_set:
1. Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after
$LASTSAVEDBY, which does not exist before R2004, so on an R12 (AC1009) or
R2000 (AC1015) document the write reported ok, drawing_properties_get
echoed it from memory, and drawing_save_as (version kept) wrote nothing --
the class f3ff703 removed for CANNOSCALE. The headless engine now refuses
with a ValueError naming the DXF version and "save as R2004 or newer",
before custom_vars is touched; the dwgprops capability reason no longer
claims custom_properties_supported unconditionally.
2. A line break in a custom key or value passed validation, ezdxf wrote it
unescaped, and the saved DXF failed to reopen (DXFStructureError) -- the
whole drawing lost, reported as success. Refused by name in the shared
validator.
3. Measured on AutoCAD 2026: AddCustomInfo rejects keys with leading/trailing
whitespace and keys containing '=', ';' or a no-break space as 'Invalid
key' -- mid-write, after the summary and earlier keys were applied, as an
unnamed COM error -- while the headless engine wrote them. The shared
validator now mirrors those rules, so both engines refuse before writing
(internal spaces, tabs, unicode and 300-char keys stay accepted, as
AutoCAD accepts them).
Pinned by save+reopen tests on R12/R2000 (refused, file reads back empty),
R2004 (round-trips), the newline case (refused, the file still opens), and
the fake-SummaryInfo COM test (no ActiveX call reaches SummaryInfo).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
10a7372ce7
commit
2b9791fbcd
@@ -515,6 +515,49 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
|||||||
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
|
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(
|
def validate_drawing_properties(
|
||||||
summary: dict | None, custom: dict | None
|
summary: dict | None, custom: dict | None
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[str]]:
|
) -> 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
|
string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never
|
||||||
coerced with ``str()`` — a number the caller meant as text is theirs to
|
coerced with ``str()`` — a number the caller meant as text is theirs to
|
||||||
format.
|
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):
|
if summary is not None and not isinstance(summary, dict):
|
||||||
raise TypeError("summary: must be an object of {field: text}")
|
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():
|
for key, value in (custom or {}).items():
|
||||||
if not isinstance(key, str) or not key.strip():
|
if not isinstance(key, str) or not key.strip():
|
||||||
raise TypeError(f"custom: key {key!r} must be a non-empty string")
|
raise TypeError(f"custom: key {key!r} must be a non-empty string")
|
||||||
|
_check_custom_key(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
to_delete.append(key)
|
to_delete.append(key)
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
|
_check_custom_text(key, value, "value")
|
||||||
to_write[key] = value
|
to_write[key] = value
|
||||||
else:
|
else:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
|
|||||||
@@ -1117,7 +1117,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
),
|
),
|
||||||
"dwgprops": FeatureCapability(
|
"dwgprops": FeatureCapability(
|
||||||
False,
|
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():
|
def _sync():
|
||||||
doc = self._require_doc()
|
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
|
custom_vars = doc.header.custom_vars
|
||||||
for key, value in to_write.items():
|
for key, value in to_write.items():
|
||||||
if custom_vars.has_tag(key):
|
if custom_vars.has_tag(key):
|
||||||
|
|||||||
@@ -6962,8 +6962,14 @@ async def drawing_properties_set(
|
|||||||
engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has
|
engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has
|
||||||
no slot for it; custom properties still work headlessly), a non-string
|
no slot for it; custom properties still work headlessly), a non-string
|
||||||
value (`TypeError` naming the field or key — nothing is coerced with
|
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
|
`str()`), an empty custom key, a custom key AutoCAD's AddCustomInfo would
|
||||||
with AddCustomInfo when new and changed with SetCustomByKey when present.
|
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 = {
|
summary = {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -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
|
ezdxf cannot write, so any non-null summary field is refused with
|
||||||
`capability: dwgprops` before anything is touched; custom properties are
|
`capability: dwgprops` before anything is touched; custom properties are
|
||||||
`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both
|
`$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
|
from __future__ import annotations
|
||||||
@@ -42,6 +50,19 @@ def test_validation_splits_writes_and_deletes():
|
|||||||
(None, {"": "x"}, TypeError, "non-empty"),
|
(None, {"": "x"}, TypeError, "non-empty"),
|
||||||
("Gearbox", None, TypeError, "summary"),
|
("Gearbox", None, TypeError, "summary"),
|
||||||
(None, ["PROJECT"], TypeError, "custom"),
|
(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):
|
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)
|
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 ─────────────────────────────────────────────────────────
|
# ── 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"}
|
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):
|
async def test_summary_is_refused_headlessly_before_any_write(backend):
|
||||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||||
await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"})
|
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):
|
with pytest.raises(ValueError):
|
||||||
await backend.drawing_properties_set(summary={"edition": "1"})
|
await backend.drawing_properties_set(summary={"edition": "1"})
|
||||||
assert info.calls == [] and info.Title == "Old title"
|
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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user