feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines
Summary fields are refused headlessly with capability dwgprops before any write; custom properties are $CUSTOMPROPERTYTAG/$CUSTOMPROPERTY pairs and survive save + reopen. Validation is shared, by name, and never str()-coerces. 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
75c13196b9
commit
10a7372ce7
@@ -36,6 +36,7 @@ from .base import (
|
||||
rad2deg,
|
||||
shoelace_area,
|
||||
)
|
||||
from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -195,6 +196,9 @@ def _msp():
|
||||
|
||||
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
|
||||
|
||||
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
||||
_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS}
|
||||
|
||||
|
||||
def _ensure_linetype_loaded(name: str) -> None:
|
||||
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
|
||||
@@ -815,6 +819,7 @@ class ComBackend(AutoCADBackend):
|
||||
"solid_3d": _solid_3d_capability(),
|
||||
"lisp": FeatureCapability(True, "sanitized"),
|
||||
"registry_sysvar": FeatureCapability(True, "native"),
|
||||
"dwgprops": FeatureCapability(True, "native"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4257,3 +4262,61 @@ class ComBackend(AutoCADBackend):
|
||||
return _entity_info(xline)
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
# ── settings (track E, group C) ───────────────────────────────────────────
|
||||
|
||||
async def drawing_properties_get(self) -> dict:
|
||||
def _sync():
|
||||
info = _acad_doc().SummaryInfo
|
||||
summary = {
|
||||
field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items()
|
||||
}
|
||||
custom: dict[str, str] = {}
|
||||
for index in range(int(info.NumCustomInfo())):
|
||||
key, value = info.GetCustomByIndex(index)
|
||||
custom[str(key)] = str(value)
|
||||
return {
|
||||
"summary": summary,
|
||||
"summary_available": True,
|
||||
"custom": custom,
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def drawing_properties_set(
|
||||
self, summary: dict | None = None, custom: dict | None = None
|
||||
) -> dict:
|
||||
# Validated before any ActiveX call: a bad field or value raises here
|
||||
# and SummaryInfo is never touched.
|
||||
written, to_write, to_delete = validate_drawing_properties(summary, custom)
|
||||
|
||||
def _sync():
|
||||
info = _acad_doc().SummaryInfo
|
||||
existing: set[str] = set()
|
||||
for index in range(int(info.NumCustomInfo())):
|
||||
key, _value = info.GetCustomByIndex(index)
|
||||
existing.add(str(key))
|
||||
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:
|
||||
info.SetCustomByKey(key, value)
|
||||
else:
|
||||
info.AddCustomInfo(key, value)
|
||||
deleted = []
|
||||
for key in to_delete:
|
||||
if key in existing:
|
||||
info.RemoveCustomByKey(key)
|
||||
deleted.append(key)
|
||||
return {
|
||||
"ok": True,
|
||||
"summary_written": sorted(written),
|
||||
"custom_written": sorted(to_write),
|
||||
"custom_deleted": deleted,
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
@@ -505,3 +505,53 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document properties (DWGPROPS) — validation shared by both engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab.
|
||||
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
|
||||
|
||||
|
||||
def validate_drawing_properties(
|
||||
summary: dict | None, custom: dict | None
|
||||
) -> tuple[dict[str, str], dict[str, str], list[str]]:
|
||||
"""→ (summary fields to write, custom keys to write, custom keys to delete).
|
||||
|
||||
Refuses the whole request by name before either engine writes anything:
|
||||
an unknown summary field is a ``ValueError``; a non-string summary value,
|
||||
a non-string or empty custom key, or a custom value that is neither a
|
||||
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.
|
||||
"""
|
||||
if summary is not None and not isinstance(summary, dict):
|
||||
raise TypeError("summary: must be an object of {field: text}")
|
||||
written: dict[str, str] = {}
|
||||
for field, value in (summary or {}).items():
|
||||
if field not in SUMMARY_FIELDS:
|
||||
raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})")
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}")
|
||||
written[field] = value
|
||||
if custom is not None and not isinstance(custom, dict):
|
||||
raise TypeError("custom: must be an object of {key: text | null}")
|
||||
to_write: dict[str, str] = {}
|
||||
to_delete: list[str] = []
|
||||
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")
|
||||
if value is None:
|
||||
to_delete.append(key)
|
||||
elif isinstance(value, str):
|
||||
to_write[key] = value
|
||||
else:
|
||||
raise TypeError(
|
||||
f"custom[{key!r}]: must be a string or null (null deletes the key), "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
return written, to_write, to_delete
|
||||
|
||||
@@ -39,7 +39,12 @@ from .base import (
|
||||
normalize_lineweight,
|
||||
shoelace_area,
|
||||
)
|
||||
from .contracts.settings import parse_scale, scale_value
|
||||
from .contracts.settings import (
|
||||
SUMMARY_FIELDS,
|
||||
parse_scale,
|
||||
scale_value,
|
||||
validate_drawing_properties,
|
||||
)
|
||||
from .quarantine import (
|
||||
AbandonedCall,
|
||||
DocumentQuarantineError,
|
||||
@@ -1110,6 +1115,10 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"registry_sysvar": FeatureCapability(
|
||||
False, reason="registry_saved_variables_have_no_home_in_a_file"
|
||||
),
|
||||
"dwgprops": FeatureCapability(
|
||||
False,
|
||||
reason="summary_fields_need_live_autocad;custom_properties_supported",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6086,3 +6095,55 @@ class EzdxfBackend(AutoCADBackend):
|
||||
else:
|
||||
entry.dxf.value = name
|
||||
return name
|
||||
|
||||
async def drawing_properties_get(self) -> dict:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
return {
|
||||
"summary": {field: None for field in SUMMARY_FIELDS},
|
||||
"summary_available": False,
|
||||
"custom": {tag: value for tag, value in doc.header.custom_vars},
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def drawing_properties_set(
|
||||
self, summary: dict | None = None, custom: dict | None = None
|
||||
) -> dict:
|
||||
written, to_write, to_delete = validate_drawing_properties(summary, custom)
|
||||
if written:
|
||||
# The five summary fields live in the DWG SummaryInfo stream, which
|
||||
# a DXF has no slot for; refuse before the custom keys are touched.
|
||||
raise UnsupportedCapabilityError(
|
||||
"dwgprops",
|
||||
"drawing_properties_set: the summary fields (title, subject, author, keywords, "
|
||||
"comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot "
|
||||
f"write (asked for {sorted(written)}). Custom properties work on both engines; "
|
||||
"set the summary on the live COM backend.",
|
||||
)
|
||||
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
custom_vars = doc.header.custom_vars
|
||||
for key, value in to_write.items():
|
||||
if custom_vars.has_tag(key):
|
||||
custom_vars.replace(key, 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)
|
||||
deleted.append(key)
|
||||
if to_write or deleted:
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"summary_written": [],
|
||||
"custom_written": sorted(to_write),
|
||||
"custom_deleted": deleted,
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
Reference in New Issue
Block a user