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")
|
||||
|
||||
|
||||
#: 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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user