feat(plugin): name-addressed XRecord CRUD on the HostApi (API v8)

Add create/read/update/delete/list for XRecord objects under
named-object-dictionary paths to the plugin HostApi, with five additive
PluginRequest/PluginResponse variants, dispatch through the shared
handler, in-process HostSession methods and both out-of-process proxies.

- dictionary paths walk from the NOD root; create seeds missing
  dictionaries (ensure_image_dictionary bootstrap) and refuses existing
  names; update refuses missing ones; refusals precede the undo step
- entry group codes validated (1..=369, never 5/105); payload content is
  the plugin's own
- each mutating call is one undo step: the pending history snapshot
  restores the objects map byte-exactly, auto-created dictionaries
  included (tested)
- typed entries survive DWG and DXF write/reload round-trips (tested);
  update drops verbatim raw bytes so saves emit the new content
- bump API_VERSION 7 -> 8 with the build.rs version_info in lockstep,
  mirroring the v7 addition; MIN_SUPPORTED stays 3
This commit is contained in:
Sebastian committed 2026-10-11 19:57:20 +02:00
1 parent 6cde0e02d8
commit ca2f0c9857
11 files changed
+1212 -3

No files matched your search

+1 -1
View File
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": opencadcodec.version.to_string(),
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 7,
"api_version": 8,
"api_version_min_supported": 3,
"build_timestamp": build_timestamp,
});
+79
View File
@@ -1085,6 +1085,85 @@ pub trait HostApi {
let _ = cmd;
false
}
// ── XRecord objects (added in API v8; appended at the end to keep vtable
// indices stable for older plugins) ────────────────────────────────────────
/// Create an XRecord named `name` under the dictionary addressed by
/// `dictionary_path`, storing `entries` (API v8, additive).
///
/// The path is walked from the drawing's named-object dictionary root: an
/// empty path addresses the root itself, and missing intermediate
/// dictionaries are created, so the first call seeds a plugin's namespace.
/// Every refusal (empty or invalid names, a path fragment that names a
/// non-dictionary object, a name already in use — comparison is
/// case-insensitive like dictionary lookups) happens **before** any change,
/// so a failure leaves the drawing untouched. The record is registered in
/// its dictionary and owned by it; the returned handle stays stable across
/// updates. The change is one undo step.
fn create_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
_entries: Vec<XRecordEntry>,
) -> Result<Handle, String> {
Err("XRecord creation is not supported by this host".to_owned())
}
/// Read the entries of an existing XRecord by name (API v8, additive).
///
/// Resolves `dictionary_path` under the named-object dictionary root and
/// returns the record's entries verbatim. Fails when the path, the name or
/// the record is missing, or when the name addresses a different object
/// kind.
fn read_xrecord(
&self,
_dictionary_path: &[&str],
_name: &str,
) -> Result<Vec<XRecordEntry>, String> {
Err("XRecord reads are not supported by this host".to_owned())
}
/// Atomically replace the entries of an existing XRecord (API v8,
/// additive). Returns the record's handle.
///
/// Neither the path nor its dictionaries are created: an update must never
/// create anything silently. Identity (handle, owning dictionary) stays;
/// the entries and their DWG handle-reference stream are replaced and the
/// record's verbatim raw payloads are dropped, so subsequent saves emit the
/// new content. A refusal leaves the drawing untouched; a successful change
/// is one undo step whose undo restores the previous entries byte-exactly.
fn update_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
_entries: Vec<XRecordEntry>,
) -> Result<Handle, String> {
Err("XRecord updates are not supported by this host".to_owned())
}
/// Delete the XRecord named `name` and its dictionary entry (API v8,
/// additive). Returns the removed record, so audits keep the prior
/// content. A refusal leaves the drawing untouched; a successful delete is
/// one undo step.
fn delete_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
) -> Result<XRecord, String> {
Err("XRecord deletes are not supported by this host".to_owned())
}
/// List the XRecord names under the dictionary addressed by
/// `dictionary_path` (API v8, additive).
///
/// Returns only the dictionary keys that address XRecord objects, in the
/// dictionary's own order, so consumers can audit and export without
/// knowing names in advance. Fails when the path does not address a
/// dictionary.
fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result<Vec<String>, String> {
Err("XRecord listing is not supported by this host".to_owned())
}
}
/// Configuration properties for creating or modifying a layer.
+153
View File
@@ -603,6 +603,95 @@ impl HostApi for PluginHostApi {
}
}
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.client.request(PluginRequest::CreateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordCreateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<crate::host::XRecordEntry>, String> {
match self.client.request(PluginRequest::ReadXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordEntries(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.client.request(PluginRequest::UpdateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordUpdateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<crate::host::XRecord, String> {
match self.client.request(PluginRequest::DeleteXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
match self.client.request(PluginRequest::ListXRecords {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
}) {
Ok(PluginResponse::XRecordNames(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
/// Sentinel reader used when the shared-memory view could not be initialized.
@@ -810,4 +899,68 @@ mod tests {
assert!(api.remove_entity(Handle::new(7)));
peer_handle.join().unwrap();
}
#[test]
fn xrecord_requests_await_their_typed_responses() {
let (mut api, mut peer) = make_client();
let peer_handle = thread::spawn(move || {
let msg = recv::<PluginToHost>(&mut peer).unwrap();
match msg {
PluginToHost::Request(req) => match *req {
PluginRequest::CreateXRecord {
dictionary_path,
name,
entries,
} => {
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
assert_eq!(name, "alpha");
assert_eq!(
entries,
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
);
}
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
send(
&mut peer,
&HostToPlugin::Response(Box::new(PluginResponse::XRecordCreateResult(Ok(
Handle::new(21),
)))),
)
.unwrap();
let msg = recv::<PluginToHost>(&mut peer).unwrap();
match msg {
PluginToHost::Request(req) => match *req {
PluginRequest::ListXRecords { dictionary_path } => {
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
}
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
send(
&mut peer,
&HostToPlugin::Response(Box::new(PluginResponse::XRecordNames(Ok(vec![
"alpha".to_owned()
])))),
)
.unwrap();
});
assert_eq!(
api.create_xrecord(
&["CVIFC"],
"alpha",
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
)
.unwrap(),
Handle::new(21)
);
assert_eq!(
api.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned()]
);
peer_handle.join().unwrap();
}
}
+36
View File
@@ -200,6 +200,32 @@ pub enum PluginRequest {
RunCommand {
request: crate::host::CommandRequest,
},
/// V8 (additive): create an XRecord under a named-object-dictionary path.
CreateXRecord {
dictionary_path: Vec<String>,
name: String,
entries: Vec<crate::host::XRecordEntry>,
},
/// V8 (additive): read the entries of an existing XRecord.
ReadXRecord {
dictionary_path: Vec<String>,
name: String,
},
/// V8 (additive): replace the entries of an existing XRecord.
UpdateXRecord {
dictionary_path: Vec<String>,
name: String,
entries: Vec<crate::host::XRecordEntry>,
},
/// V8 (additive): delete an XRecord and its dictionary entry.
DeleteXRecord {
dictionary_path: Vec<String>,
name: String,
},
/// V8 (additive): list the XRecord names under a dictionary path.
ListXRecords {
dictionary_path: Vec<String>,
},
}
/// Responses the host sends back for `PluginRequest`.
@@ -236,6 +262,16 @@ pub enum PluginResponse {
SolidResult(Result<Handle, String>),
TableResult(Result<Handle, String>),
CommandResult(Result<crate::host::CommandOutcome, String>),
/// V8 (additive): handle of a created XRecord.
XRecordCreateResult(Result<Handle, String>),
/// V8 (additive): entries of a read XRecord.
XRecordEntries(Result<Vec<crate::host::XRecordEntry>, String>),
/// V8 (additive): handle of an updated XRecord.
XRecordUpdateResult(Result<Handle, String>),
/// V8 (additive): the removed XRecord, with its prior content.
XRecordDeleteResult(Result<crate::host::XRecord, String>),
/// V8 (additive): XRecord names under a dictionary path.
XRecordNames(Result<Vec<String>, String>),
}
/// Messages sent from the host to the plugin runner.
+34
View File
@@ -117,5 +117,39 @@ pub fn handle_plugin_request(
PluginResponse::TableResult(host.table_operation(operation))
}
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
CreateXRecord {
dictionary_path,
name,
entries,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordCreateResult(host.create_xrecord(&path, &name, entries))
}
ReadXRecord {
dictionary_path,
name,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordEntries(host.read_xrecord(&path, &name))
}
UpdateXRecord {
dictionary_path,
name,
entries,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordUpdateResult(host.update_xrecord(&path, &name, entries))
}
DeleteXRecord {
dictionary_path,
name,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordDeleteResult(host.delete_xrecord(&path, &name))
}
ListXRecords { dictionary_path } => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordNames(host.list_xrecords(&path))
}
}
}
@@ -898,6 +898,95 @@ impl HostApi for V4PluginHostApi {
}
}
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.request(PluginRequest::CreateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordCreateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<crate::host::XRecordEntry>, String> {
match self.request(PluginRequest::ReadXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordEntries(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.request(PluginRequest::UpdateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordUpdateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<crate::host::XRecord, String> {
match self.request(PluginRequest::DeleteXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
match self.request(PluginRequest::ListXRecords {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
}) {
Ok(PluginResponse::XRecordNames(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
#[cfg(test)]
@@ -388,6 +388,111 @@ mod tests {
);
}
#[test]
fn xrecord_request_and_response_roundtrip() {
// The five XRecord request variants (API v8) serialize with their
// dictionary paths and typed entries, and the responses answer with
// handles, entries, records and names.
let entries = vec![
crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"),
crate::host::XRecordEntry::int16(70, 3),
];
let bytes = bincode::serialize(&PluginRequest::CreateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::ReadXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ReadXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::UpdateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::DeleteXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::DeleteXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::ListXRecords {
dictionary_path: vec!["CVIFC".to_owned()],
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ListXRecords { dictionary_path } if
dictionary_path == vec!["CVIFC".to_owned()])
);
let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes =
bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries)
);
let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()])))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()])
);
let deleted = crate::host::XRecord::named("alpha");
let bytes =
bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted)
);
let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err(
"XRecord reads are not supported by this host".to_owned(),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported"))
);
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {
+5 -2
View File
@@ -21,8 +21,11 @@
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
/// `HostApi::document_path`. v6 appends generic host system-variable access
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
/// undoable replacement of a set of existing entities.
pub const API_VERSION: u32 = 7;
/// undoable replacement of a set of existing entities. v8 appends name-addressed
/// XRecord object CRUD under named-object-dictionary paths
/// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` /
/// `delete_xrecord` / `list_xrecords`).
pub const API_VERSION: u32 = 8;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
+18
View File
@@ -339,3 +339,21 @@ plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python
`--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint`
now passes, fixed upstream). The change is 72 files, +30,705 / -178 against current `main` (fork-only workflow excluded).
### 2026-10-11: XRecord objects (API v8, no Python surface yet)
New additive v8 requests `CreateXRecord` / `ReadXRecord` / `UpdateXRecord` / `DeleteXRecord` / `ListXRecords`
give plugins a non-graphical record carrier: name-addressed typed `XRecordEntry` pairs under the drawing's
named-object dictionary, with no carrier entity. `dictionary_path` is walked from the NOD root (an empty path
is the root), creating missing dictionaries on create only — the `ensure_image_dictionary` bootstrap — so the
first call seeds a namespace; `create` refuses an existing name and `update` refuses a missing one, so neither
can silently overwrite or create. Names and path fragments are symbol-validated, entry group codes are checked
(1..=369, never the handle codes 5/105), and every refusal is decided before the undo step is recorded. Update
replaces the entries atomically (handle, owner and name stay, the record's verbatim raw payloads are dropped, so
saves emit the new content); delete returns the removed record; list reports only the keys that address
XRecords, in dictionary order. Undo: objects-map mutations are captured by the pending history's full structure
snapshot, so each mutating call is one undo step whose undo restores the objects map byte-exactly, the
auto-created dictionaries included. Evidence: `xrecord_crud_round_trips_refusals_and_list`,
`xrecord_mutations_are_single_undo_steps_that_restore_objects` (four steps, refusal paths record none),
`xrecords_survive_dxf_and_dwg_round_trips` (typed entries reload from both writers), the IPC round-trips
`xrecord_request_and_response_roundtrip` and `xrecord_requests_await_their_typed_responses`. The API is
host-level and stands on its own; a Python mapping (`doc.xrecords`-style) is future work.
+17
View File
@@ -26,6 +26,7 @@ without it keeps working, and the default `HostApi` methods refuse.
| Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). |
| Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. |
| Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). |
| Objects | `create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord`, `list_xrecords` | Name-addressed XRecords under the drawing's named-object dictionary (API v8, see below): a non-graphical record carrier with strict create-vs-update semantics and one undo step per change. |
| Commands | `run_command` | Drives the real OCS command one step at a time (see below). |
| Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). |
@@ -85,6 +86,22 @@ recorded, so a failure changes nothing and leaves the history alone.
Style and layout operations act on the active document, because they use the
application's shared style and view machinery.
## XRecords (API v8)
`create_xrecord`, `read_xrecord`, `update_xrecord`, `delete_xrecord` and
`list_xrecords` address XRecords by name under a dictionary path walked from the
named-object dictionary root (an empty path is the root itself); missing
dictionaries are created on create only, so the first call seeds a plugin's
namespace. The payload is the codec's typed `XRecordEntry` pairs — the host puts
no opinion on the content. Names and path fragments are symbol-validated, and
dictionary addressing follows the codec's case-insensitive lookup. Create never
overwrites and update never creates; each refusal is decided before the undo step
is recorded, so a failure changes nothing, and each change is one undo step whose
undo restores the objects map byte-exactly, the auto-created dictionaries
included. `list_xrecords` reports only the keys that address XRecords, in the
dictionary's own order, so audits need no prior knowledge. Not yet mapped to
Python.
## Commands
`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`,
+675
View File
@@ -2777,6 +2777,324 @@ impl<'a> HostSession<'a> {
self.publish_document_view();
true
}
// ── XRecord objects (API v8, additive) ────────────────────────────────────
//
// XRecords are non-graphical objects: name-addressed typed group-code/value
// pairs stored under the drawing's named-object dictionary, like AutoCAD
// keeps application data. They give scripts a record carrier that lives in
// the file and survives model view entirely, with no carrier entity.
/// The dictionary addressed by `dictionary_path` under the named-object
/// dictionary root (an empty path is the root itself). Nothing is created:
/// a missing fragment is an error, and so is a fragment that exists but
/// names a non-dictionary object.
fn xrecord_dictionary_at(&self, dictionary_path: &[&str]) -> Result<Handle, String> {
use codec::objects::ObjectType;
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
}
None => return Err(format!("dictionary {name:?} does not exist")),
},
_ => return Err(format!("dictionary {current:?} does not exist")),
}
}
Ok(current)
}
/// The deepest ancestor of `dictionary_path` that already exists, and how
/// deep the path resolved. Errors when a fragment that exists names a
/// non-dictionary object. Creation calls this first so its refusals happen
/// before the undo step is recorded.
fn deepest_existing_xrecord_dictionary(
&self,
dictionary_path: &[&str],
) -> Result<(Handle, usize), String> {
use codec::objects::ObjectType;
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
let mut depth = 0usize;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
depth += 1;
}
None => break,
},
_ => return Err(format!("dictionary {current:?} does not exist")),
}
}
Ok((current, depth))
}
/// Walk `dictionary_path` from the named-object dictionary root, creating
/// missing dictionaries on the way — the same bootstrap
/// `ensure_image_dictionary` uses for ACAD_IMAGE_DICT: each new dictionary
/// owns its handle, is owned by its parent and is registered in the parent
/// under its name. Mutates; only valid after the caller validated the path
/// and recorded its undo step.
fn xrecord_dictionary_at_or_create(
&mut self,
dictionary_path: &[&str],
) -> Result<Handle, String> {
use codec::objects::{Dictionary, ObjectType};
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
let existing = match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => dictionary.get(&name),
_ => return Err(format!("dictionary {current:?} does not exist")),
};
match existing {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
}
None => {
let child = self.document_mut().allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = child;
dictionary.owner = current;
self.document_mut()
.objects
.insert(child, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&current)
{
entries.add_entry(name, child);
}
current = child;
}
}
}
Ok(current)
}
/// Create an XRecord named `name` under `dictionary_path`, storing
/// `entries` (API v8, additive). Missing dictionaries on the path are
/// created, so the first call seeds a plugin's namespace; the name must be
/// unused (dictionary lookup is case-insensitive). Every refusal happens
/// before the undo step is recorded, so a failure changes nothing; the
/// created record is registered in — and owned by — its dictionary, and
/// the new handle is returned.
pub fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<codec::objects::XRecordEntry>,
) -> Result<Handle, String> {
use codec::objects::{ObjectType, XRecord};
let name = validated_symbol_name(name, "an XRecord name")?;
validated_xrecord_entries(&entries)?;
// Pre-check the path: fragments that already exist must be
// dictionaries, and when the full path exists the name must be free —
// create must never silently overwrite a key.
let (deepest, depth) = self.deepest_existing_xrecord_dictionary(dictionary_path)?;
if depth == dictionary_path.len() {
if let Some(ObjectType::Dictionary(dictionary)) = self.document().objects.get(&deepest)
{
if dictionary.get(&name).is_some() {
return Err(format!("XRecord {name:?} already exists"));
}
}
}
self.push_undo("Create XRecord");
// The pre-check ruled out every failure mode; the walk now only creates
// the dictionary fragments that were missing.
let dictionary = self
.xrecord_dictionary_at_or_create(dictionary_path)
.expect("validated dictionary path");
let handle = self.document_mut().allocate_handle();
let mut record = XRecord::named(name.clone());
record.handle = handle;
record.owner = dictionary;
record.entries = entries;
record.synchronize_object_references();
self.document_mut()
.objects
.insert(handle, ObjectType::XRecord(record));
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&dictionary)
{
entries.add_entry(name, handle);
}
self.set_dirty();
self.publish_document_view();
Ok(handle)
}
/// Read the entries of an existing XRecord by name (API v8, additive).
/// The entries are returned verbatim; the path, the name and the target
/// kind are validated first, so a refusal changes nothing.
pub fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<codec::objects::XRecordEntry>, String> {
use codec::objects::ObjectType;
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
match self.document().objects.get(&handle) {
Some(ObjectType::XRecord(record)) => Ok(record.entries.clone()),
_ => unreachable!("validated XRecord"),
}
}
/// Atomically replace the entries of an existing XRecord (API v8,
/// additive). Neither the path nor its dictionaries are created — an
/// update must never create anything silently. Identity (handle, owning
/// dictionary, name) stays; the entries and their DWG handle-reference
/// stream are replaced and the record's verbatim raw payloads are dropped,
/// so later saves emit the new content. Every refusal happens before the
/// undo step is recorded.
pub fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<codec::objects::XRecordEntry>,
) -> Result<Handle, String> {
use codec::objects::ObjectType;
validated_xrecord_entries(&entries)?;
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
self.push_undo("Update XRecord");
let record = match self.document_mut().objects.get_mut(&handle) {
Some(ObjectType::XRecord(record)) => record,
_ => unreachable!("validated XRecord"),
};
record.entries = entries;
// Rebuild the DWG object-reference stream from the new 330-369 entries
// and drop the verbatim bytes an earlier load kept: the update fully
// defines the record now.
record.synchronize_object_references();
record.preserve_object_reference_stream = false;
record.entries_complete = true;
record.raw_data.clear();
record.raw_dwg_data = None;
record.raw_dwg_handle_bits = 0;
record.raw_dwg_version = None;
self.set_dirty();
self.publish_document_view();
Ok(handle)
}
/// Delete the XRecord named `name` and its dictionary entry (API v8,
/// additive). Returns the removed record with its prior content, so audits
/// keep what they removed. Every refusal happens before the undo step is
/// recorded.
pub fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<codec::objects::XRecord, String> {
use codec::objects::ObjectType;
let (dictionary, name, handle) = self.xrecord_lookup(dictionary_path, name)?;
self.push_undo("Delete XRecord");
let removed = match self.document_mut().objects.remove(&handle) {
Some(ObjectType::XRecord(record)) => record,
_ => unreachable!("validated XRecord"),
};
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&dictionary)
{
entries
.entries
.retain(|(key, value)| !key.eq_ignore_ascii_case(&name) || *value != handle);
}
self.set_dirty();
self.publish_document_view();
Ok(removed)
}
/// List the XRecord names under the dictionary addressed by
/// `dictionary_path` (API v8, additive): the dictionary keys that address
/// XRecord objects, in the dictionary's own order, so consumers can audit
/// and export without knowing names in advance. Non-XRecord entries are
/// not listed.
pub fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
use codec::objects::ObjectType;
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
return Err(format!("dictionary {dictionary:?} does not exist"));
};
Ok(entries
.entries
.iter()
.filter_map(|(key, handle)| match self.document().objects.get(handle) {
Some(ObjectType::XRecord(_)) => Some(key.clone()),
_ => None,
})
.collect())
}
/// Resolve an existing XRecord to its containing dictionary, its validated
/// name and its handle, after validating the path, the name and the target
/// kind.
fn xrecord_lookup(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<(Handle, String, Handle), String> {
use codec::objects::ObjectType;
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
let name = validated_symbol_name(name, "an XRecord name")?;
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
return Err(format!("dictionary {dictionary:?} does not exist"));
};
let Some(handle) = entries.get(&name) else {
return Err(format!("XRecord {name:?} does not exist"));
};
if !matches!(
self.document().objects.get(&handle),
Some(ObjectType::XRecord(_))
) {
return Err(format!("dictionary entry {name:?} is not an XRecord"));
}
Ok((dictionary, name, handle))
}
}
/// True when block `outer` (transitively) contains an insert of `target`.
@@ -2824,6 +3142,28 @@ fn validated_symbol_name(raw: &str, what: &str) -> Result<String, String> {
Ok(name.to_owned())
}
/// Validate the entries an XRecord create or update wants to store: each needs
/// a group code the codec model admits (1..=369; 5 and 105 are reserved for
/// object handles). Refused before any change so bad input never reaches a
/// file.
fn validated_xrecord_entries(entries: &[codec::objects::XRecordEntry]) -> Result<(), String> {
for entry in entries {
if !(1..=369).contains(&entry.code) {
return Err(format!(
"XRecord group code {} is out of range 1..=369",
entry.code
));
}
if entry.code == 5 || entry.code == 105 {
return Err(format!(
"XRecord group code {} is reserved for the object handle",
entry.code
));
}
}
Ok(())
}
fn apply_text_style_config(
style: &mut codec::tables::TextStyle,
config: &ocs_plugin_api::host::TextStyleConfig,
@@ -3100,6 +3440,39 @@ impl HostApi for HostSession<'_> {
) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
self.run_command(request)
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
) -> Result<Handle, String> {
self.create_xrecord(dictionary_path, name, entries)
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<ocs_plugin_api::host::XRecordEntry>, String> {
self.read_xrecord(dictionary_path, name)
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
) -> Result<Handle, String> {
self.update_xrecord(dictionary_path, name, entries)
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<ocs_plugin_api::host::XRecord, String> {
self.delete_xrecord(dictionary_path, name)
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
self.list_xrecords(dictionary_path)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
@@ -3600,6 +3973,308 @@ mod tests {
);
}
#[test]
fn xrecord_crud_round_trips_refusals_and_list() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let entries = || {
vec![
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
codec::objects::XRecordEntry::double(40, 0.125),
codec::objects::XRecordEntry::int16(70, 3),
codec::objects::XRecordEntry::int32(90, -45000),
codec::objects::XRecordEntry::bool(290, true),
]
};
// Create seeds the namespace: the dictionary chain is auto-created and
// reading returns the entries verbatim.
let alpha = host.create_xrecord(&["CVIFC"], "alpha", entries()).unwrap();
assert!(!alpha.is_null());
assert_eq!(host.read_xrecord(&["CVIFC"], "alpha").unwrap(), entries());
// Dictionary lookup is case-insensitive, both for the path …
assert_eq!(host.read_xrecord(&["cvifc"], "alpha").unwrap(), entries());
// … and re-creating an existing name is refused without any change.
assert!(host
.create_xrecord(&["cvifc"], "ALPHA", entries())
.unwrap_err()
.contains("already exists"));
// Namespace reuse is idempotent: the root still holds one CVIFC entry.
let beta = host.create_xrecord(&["CVIFC"], "beta", entries()).unwrap();
let root = host.document().header.named_objects_dict_handle;
let cvifc_entries = match host.document().objects.get(&root) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
.entries
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case("CVIFC"))
.count(),
_ => 0,
};
assert_eq!(cvifc_entries, 1);
// Listing names only the keys that address XRecords, in dictionary
// order; a nested dictionary is not an XRecord.
host.create_xrecord(&["CVIFC", "LAYOUTS"], "nested", entries())
.unwrap();
assert_eq!(
host.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned(), "beta".to_owned()]
);
assert_eq!(
host.list_xrecords(&["CVIFC", "LAYOUTS"]).unwrap(),
vec!["nested".to_owned()]
);
// The root dictionary holds non-XRecord entries (ACAD_LAYOUT et al.);
// listing only reports XRecords.
let root_names = host.list_xrecords(&[]).unwrap();
assert!(!root_names
.iter()
.any(|n| n.eq_ignore_ascii_case("ACAD_LAYOUT")));
// Update refuses on a missing name and never creates anything.
assert!(host
.update_xrecord(&["CVIFC"], "gamma", entries())
.unwrap_err()
.contains("does not exist"));
// Case-insensitive resolution finds the stored ALPHA and replaces its
// entries atomically; the handle is stable.
assert_eq!(
host.update_xrecord(
&["CVIFC"],
"ALPHA",
vec![codec::objects::XRecordEntry::string(1, "v2")]
)
.unwrap(),
alpha
);
assert_eq!(
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
vec![codec::objects::XRecordEntry::string(1, "v2")]
);
// Entries are validated before any change: reserved and out-of-range
// group codes refuse (5 and 105 are the object handle codes).
assert!(host
.create_xrecord(
&["CVIFC"],
"gamma",
vec![codec::objects::XRecordEntry::int16(5, 1)]
)
.unwrap_err()
.contains("reserved"));
assert!(host
.create_xrecord(
&["CVIFC"],
"gamma",
vec![codec::objects::XRecordEntry::int16(400, 1)]
)
.unwrap_err()
.contains("out of range"));
assert!(host
.update_xrecord(
&["CVIFC"],
"alpha",
vec![codec::objects::XRecordEntry::int16(105, 1)]
)
.is_err());
assert_eq!(
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
vec![codec::objects::XRecordEntry::string(1, "v2")]
);
// Dictionary and record names are symbol names.
assert!(host.create_xrecord(&["CVIFC"], " ", entries()).is_err());
assert!(host
.create_xrecord(&["CVIFC", "A|B"], "gamma", entries())
.is_err());
assert!(host.create_xrecord(&["CVIFC"], "a<b", entries()).is_err());
// Reads distinguish a missing dictionary, a missing name and a name
// that addresses another object kind.
assert!(host
.read_xrecord(&["NOPE"], "alpha")
.unwrap_err()
.contains("does not exist"));
assert!(host
.read_xrecord(&["CVIFC", "LAYOUTS"], "alpha")
.unwrap_err()
.contains("does not exist"));
assert!(host
.delete_xrecord(&["CVIFC"], "LAYOUTS")
.unwrap_err()
.contains("not an XRecord"));
// Delete removes exactly the named record and returns its content.
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
assert_eq!(removed.handle, beta);
assert_eq!(removed.entries, entries());
assert_eq!(
host.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned()]
);
assert!(host
.read_xrecord(&["CVIFC"], "beta")
.unwrap_err()
.contains("does not exist"));
assert!(host.delete_xrecord(&["CVIFC"], "beta").is_err());
}
#[test]
fn xrecord_mutations_are_single_undo_steps_that_restore_objects() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let original_entries = || vec![codec::objects::XRecordEntry::string(1, "one")];
let changed_entries = || {
vec![
codec::objects::XRecordEntry::string(1, "changed"),
codec::objects::XRecordEntry::int16(70, 9),
]
};
let (alpha, beta);
{
let mut host = HostSession::new(&mut app, 0);
alpha = host
.create_xrecord(&["CVIFC"], "alpha", original_entries())
.unwrap();
beta = host
.create_xrecord(&["CVIFC"], "beta", original_entries())
.unwrap();
// Refusals record no undo step.
assert!(host
.create_xrecord(&["CVIFC"], "alpha", original_entries())
.is_err());
assert!(host
.update_xrecord(&["CVIFC"], "gamma", original_entries())
.is_err());
host.update_xrecord(&["CVIFC"], "alpha", changed_entries())
.unwrap();
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
assert_eq!(removed.handle, beta);
}
app.finish_pending_history(0);
// Four mutating calls = four undo steps; the refusals recorded none.
assert_eq!(app.tabs[0].history.undo_stack.len(), 4);
let xrecord = |doc: &CadDocument, handle: Handle| -> Option<codec::objects::XRecord> {
match doc.objects.get(&handle) {
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
_ => None,
}
};
let has_dictionary_entry = |doc: &CadDocument, name: &str| -> bool {
let root = doc.header.named_objects_dict_handle;
match doc.objects.get(&root) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
.entries
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case(name)),
_ => false,
}
};
// Undo the delete: beta is back with its exact prior entries.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert_eq!(xrecord(document, beta).unwrap().entries, original_entries());
// Undo the update: alpha's entries are restored byte-exactly.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert_eq!(
xrecord(document, alpha).unwrap().entries,
original_entries()
);
// Undo beta's creation.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(xrecord(document, beta).is_none());
assert!(xrecord(document, alpha).is_some());
// Undo alpha's creation: the auto-created dictionary went with it,
// because the pending history snapshot restores the whole objects map.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(xrecord(document, alpha).is_none());
assert!(
!has_dictionary_entry(document, "CVIFC"),
"undoing the first create removed the auto-created dictionary"
);
// Redo re-applies the whole ladder.
app.redo_steps(4);
let document = &app.tabs[0].scene.document;
assert_eq!(xrecord(document, alpha).unwrap().entries, changed_entries());
assert!(xrecord(document, beta).is_none());
}
#[test]
fn xrecords_survive_dxf_and_dwg_round_trips() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let entries = || {
vec![
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
codec::objects::XRecordEntry::double(40, 0.125),
codec::objects::XRecordEntry::int16(70, 3),
codec::objects::XRecordEntry::int32(90, -45000),
codec::objects::XRecordEntry::bool(290, true),
]
};
// Create carries a first payload, then the update replaces it: if the
// writers preferred stale raw bytes over the typed entries, the loaded
// record would still read "stale".
host.create_xrecord(
&["CVIFC", "RECORDS"],
"alpha",
vec![codec::objects::XRecordEntry::string(1, "stale")],
)
.unwrap();
host.update_xrecord(&["CVIFC", "RECORDS"], "alpha", entries())
.unwrap();
let find_xrecord =
|doc: &CadDocument, path: &[&str], name: &str| -> Option<codec::objects::XRecord> {
let mut current = doc.header.named_objects_dict_handle;
for fragment in path {
match doc.objects.get(&current) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => {
current = dictionary.get(fragment)?
}
_ => return None,
}
}
let Some(codec::objects::ObjectType::Dictionary(dictionary)) =
doc.objects.get(&current)
else {
return None;
};
let handle = dictionary.get(name)?;
match doc.objects.get(&handle) {
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
_ => None,
}
};
let dwg = crate::io::load_bytes(
"xrec.dwg",
codec::DwgWriter::write_to_vec(host.document()).unwrap(),
)
.unwrap();
let reloaded = find_xrecord(&dwg, &["CVIFC", "RECORDS"], "alpha")
.expect("XRecord in the reloaded DWG");
assert_eq!(reloaded.entries, entries());
let dxf = crate::io::load_bytes(
"xrec.dxf",
codec::DxfWriter::new(host.document())
.write_to_vec()
.unwrap(),
)
.unwrap();
let reloaded = find_xrecord(&dxf, &["CVIFC", "RECORDS"], "alpha")
.expect("XRecord in the reloaded DXF");
assert_eq!(reloaded.entries, entries());
}
/// Opt-in end-to-end check using the staged Python cdylib and the actual
/// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN
/// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,