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

+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,