From 81bd302967a9ecfb481d9dd5643f529c9136608a Mon Sep 17 00:00:00 2001 From: "w.pomp" Date: Thu, 1 Oct 2026 18:59:31 +0200 Subject: [PATCH] - fix some discrepancies with the xsd --- Cargo.toml | 2 +- ome-metadata-derive/src/lib.rs | 4 +- ome-metadata/src/lib.rs | 62 +++++++++++ ome-metadata/src/ome.rs | 85 +++++++++++---- ome-metadata/src/py.rs | 14 +-- ome-metadata/src/xml.rs | 189 +++++++++++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 39 deletions(-) create mode 100644 ome-metadata/src/xml.rs diff --git a/Cargo.toml b/Cargo.toml index 092e9e8..f08a5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["ome-metadata", "ome-metadata-derive"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" rust-version = "1.88.0" authors = ["Wim Pomp "] diff --git a/ome-metadata-derive/src/lib.rs b/ome-metadata-derive/src/lib.rs index bcdea0a..1b88962 100644 --- a/ome-metadata-derive/src/lib.rs +++ b/ome-metadata-derive/src/lib.rs @@ -10,11 +10,11 @@ pub fn ome_metadata_derive_xml(input: TokenStream) -> TokenStream { let expanded = quote! { impl #ident { pub fn from_xml>(s: T) -> Result { - Ok(from_str(s.as_ref())?) + from_str(s.as_ref()) } pub fn to_xml(&self) -> Result { - Ok(to_string(self)?) + to_string(self) } } }; diff --git a/ome-metadata/src/lib.rs b/ome-metadata/src/lib.rs index d0d8925..2fb8471 100644 --- a/ome-metadata/src/lib.rs +++ b/ome-metadata/src/lib.rs @@ -4,6 +4,7 @@ pub mod ome; pub mod error; #[cfg(feature = "python")] pub mod py; +mod xml; pub use ome::Ome; @@ -27,6 +28,67 @@ mod tests { }; } + #[test] + fn serialize_ome_root_and_namespaces() -> Result<(), Error> { + for (uuid, creator) in [ + (None, None), + (Some("urn:uuid:3e450fae-b8f2-4d35-aa54-702168b2487f"), None), + (None, Some("example & ")), + ( + Some("urn:uuid:3e450fae-b8f2-4d35-aa54-702168b2487f"), + Some("example & "), + ), + ] { + let ome = Ome { + uuid: uuid.map(str::to_owned), + creator: creator.map(str::to_owned), + ..Ome::default() + }; + let xml = ome.to_xml()?; + let mut reader = quick_xml::NsReader::from_str(&xml); + let (namespace, event) = reader.read_resolved_event().unwrap(); + assert_eq!( + namespace, + quick_xml::name::ResolveResult::Bound(quick_xml::name::Namespace( + b"http://www.openmicroscopy.org/Schemas/OME/2016-06" + )) + ); + let root = match event { + quick_xml::events::Event::Start(root) | quick_xml::events::Event::Empty(root) => { + root + } + event => panic!("expected OME root, got {event:?}"), + }; + assert_eq!(root.name().as_ref(), b"OME"); + assert_eq!( + root.try_get_attribute("xmlns:xsi") + .unwrap() + .unwrap() + .value + .as_ref(), + b"http://www.w3.org/2001/XMLSchema-instance" + ); + assert_eq!( + root.try_get_attribute("xsi:schemaLocation").unwrap().unwrap().value.as_ref(), + b"http://www.openmicroscopy.org/Schemas/OME/2016-06 http://www.openmicroscopy.org/Schemas/OME/2016-06/ome.xsd" + ); + assert_eq!(Ome::from_xml(&xml)?, ome); + + // Exercise a nonempty root as well as the empty metadata document. + let ome = Ome { + binary_only: Some(crate::ome::OmeBinaryOnly { + metadata_file: "metadata.ome.xml".to_owned(), + uuid: "urn:uuid:3e450fae-b8f2-4d35-aa54-702168b2487f".to_owned(), + }), + ..ome + }; + let xml = ome.to_xml()?; + assert!(xml.ends_with("")); + assert_eq!(Ome::from_xml(&xml)?, ome); + } + Ok(()) + } + test_read!( a: "YTL1849A111_2023_05_04__14_46_19_cellnr_1_track" b: "Experiment-2029" diff --git a/ome-metadata/src/ome.rs b/ome-metadata/src/ome.rs index c7fa8d7..672e35d 100644 --- a/ome-metadata/src/ome.rs +++ b/ome-metadata/src/ome.rs @@ -2,6 +2,7 @@ use crate::error::Error; #[cfg(feature = "python")] use crate::py::PyDisplay; +use crate::xml::{from_str, to_string}; use ome_metadata_derive::OmeXML; #[cfg(feature = "python")] use ome_metadata_derive::{PyOme, PyOmeComplexEnum, PyOmeEnum, PyOmeUnit}; @@ -9,8 +10,6 @@ use ome_metadata_derive::{PyOme, PyOmeComplexEnum, PyOmeEnum, PyOmeUnit}; use pyo3::IntoPyObjectExt; #[cfg(feature = "python")] use pyo3::prelude::*; -use quick_xml::de::from_str; -use quick_xml::se::to_string; use serde::{Deserialize, Serialize}; use std::cmp::PartialEq; use strum::{AsRefStr, EnumString, VariantNames}; @@ -500,7 +499,7 @@ impl Channel { UnitsLength::um } pub fn default_color() -> i32 { - 0 + -1 } pub fn default_excitation_wavelength_unit() -> UnitsLength { UnitsLength::nm @@ -2031,11 +2030,7 @@ pub struct Instrument { skip_serializing_if = "Option::is_none" )] pub microscope: Option, - #[serde( - default, - rename = "LightSourceGroup", - skip_serializing_if = "Vec::is_empty" - )] + #[serde(default, rename = "$value", skip_serializing_if = "Vec::is_empty")] pub light_source_group: Vec, #[serde(default, rename = "Detector", skip_serializing_if = "Vec::is_empty")] pub detector: Vec, @@ -3310,7 +3305,7 @@ pub enum NamingConventionType { /// ``` /// use ome_metadata::Ome; /// -/// let xml = r#" +/// let xml = r#" /// /// 2025-01-29T14:42:42 /// @@ -3329,6 +3324,7 @@ pub enum NamingConventionType { /// ``` #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename = "OME")] #[cfg_attr( feature = "python", pyclass( @@ -3388,11 +3384,15 @@ pub struct Ome { } impl Ome { pub fn from_xml>(s: T) -> Result { - Ok(from_str(s.as_ref())?) + from_str(s.as_ref()) } pub fn to_xml(&self) -> Result { - Ok(to_string(self)?.replace("", r###""###).replace("", "")) + Ok(to_string(self)?.replacen( + ", - #[serde(rename = "AnnotationRef", skip_serializing_if = "Option::is_none")] - pub annotation_ref: Option, + #[serde( + default, + rename = "AnnotationRef", + skip_serializing_if = "Vec::is_empty" + )] + pub annotation_ref: Vec, } impl Plane { pub fn default_delta_t_unit() -> UnitsTime { @@ -3980,7 +3984,7 @@ impl Default for Plane { position_z: None, position_z_unit: Self::default_position_z_unit(), hash_sha1: None, - annotation_ref: None, + annotation_ref: Vec::new(), } } } @@ -4471,8 +4475,12 @@ pub struct Roi { pub name: Option, #[serde(rename = "Union", skip_serializing_if = "Option::is_none")] pub union: Option, - #[serde(rename = "AnnotationRef", skip_serializing_if = "Option::is_none")] - pub annotation_ref: Option, + #[serde( + default, + rename = "AnnotationRef", + skip_serializing_if = "Vec::is_empty" + )] + pub annotation_ref: Vec, #[serde(rename = "Description", skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -4683,7 +4691,7 @@ pub struct Rights { )] #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, OmeXML)] pub struct RoiUnion { - #[serde(default, rename = "ShapeGroup", skip_serializing_if = "Vec::is_empty")] + #[serde(default, rename = "$value", skip_serializing_if = "Vec::is_empty")] pub shape_group: Vec, } #[cfg_attr( @@ -5020,8 +5028,8 @@ impl Default for StageLabel { )] #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, OmeXML)] pub struct StructuredAnnotations { - #[serde(default, rename = "$value", skip_serializing_if = "Option::is_none")] - pub content: Option, + #[serde(default, rename = "$value", skip_serializing_if = "Vec::is_empty")] + pub content: Vec, } #[allow(clippy::enum_variant_names)] #[derive( @@ -5632,7 +5640,7 @@ pub enum UnitsTime { ), derive(FromPyObject, PyOme) )] -#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, OmeXML)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, OmeXML)] pub struct Well { #[serde(rename = "@ID")] pub id: String, @@ -5671,9 +5679,25 @@ pub struct Well { )] pub annotation_ref: Vec, } +impl Default for Well { + fn default() -> Self { + Self { + id: String::new(), + column: 0, + row: 0, + external_description: None, + external_identifier: None, + r#type: None, + color: Self::default_color(), + well_sample: Vec::new(), + reagent_ref: None, + annotation_ref: Vec::new(), + } + } +} impl Well { pub fn default_color() -> i32 { - 0 + -1 } } #[cfg_attr( @@ -5760,6 +5784,7 @@ impl Default for WellSample { derive(FromPyObject, PyOme) )] #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, OmeXML)] +#[serde(rename = "XMLAnnotation")] pub struct XmlAnnotation { #[serde(rename = "@ID")] pub id: String, @@ -5790,12 +5815,26 @@ pub struct XmlAnnotation { #[serde(rename = "Value")] pub value: XmlAnnotationValue, } +/// Arbitrary XML fragment inside an XMLAnnotation Value element. +/// Use the from_xml/to_xml methods to read or write XML; generic Serde +/// serialization represents the fragment as escaped text. #[cfg_attr( feature = "python", - pyclass(module = "ome_metadata", skip_from_py_object, eq, new = "from_fields") + pyclass( + module = "ome_metadata", + skip_from_py_object, + get_all, + set_all, + eq, + new = "from_fields" + ), + derive(FromPyObject, PyOme) )] #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, OmeXML)] -pub struct XmlAnnotationValue; +pub struct XmlAnnotationValue { + #[serde(default, rename = "$text")] + pub xml: String, +} pub trait Convert: PartialEq { /// conversion factor between this and SI value fn as_si(&self) -> Result; diff --git a/ome-metadata/src/py.rs b/ome-metadata/src/py.rs index 08bf5e0..92211ea 100644 --- a/ome-metadata/src/py.rs +++ b/ome-metadata/src/py.rs @@ -1,5 +1,5 @@ use crate::error::Error; -use crate::ome::{ExperimentType, MetadataOnly, MicrobeamManipulationType, XmlAnnotationValue}; +use crate::ome::{ExperimentType, MetadataOnly, MicrobeamManipulationType}; use pyo3::prelude::*; use pyo3::types::PyTuple; @@ -96,18 +96,6 @@ impl PyDisplay for MicrobeamManipulationType { ) } } -impl<'a, 'py> FromPyObject<'a, 'py> for XmlAnnotationValue { - type Error = Error; - - fn extract(_obj: Borrowed<'a, 'py, PyAny>) -> Result { - Ok(Self {}) - } -} -impl PyDisplay for XmlAnnotationValue { - fn py_str(&self) -> String { - "XmlAnnotationValue".to_string() - } -} impl PyDisplay for ExperimentType { fn py_str(&self) -> String { format!( diff --git a/ome-metadata/src/xml.rs b/ome-metadata/src/xml.rs new file mode 100644 index 0000000..5c0a26c --- /dev/null +++ b/ome-metadata/src/xml.rs @@ -0,0 +1,189 @@ +//! XML boundaries for retaining arbitrary XMLAnnotation payloads. +use crate::error::Error; +use quick_xml::events::Event; +use serde::{Serialize, de::DeserializeOwned}; +use std::collections::BTreeMap; + +pub(crate) fn from_str(xml: &str) -> Result { + Ok(quick_xml::de::from_str(&rewrite_values(xml, false)?)?) +} + +pub(crate) fn to_string(value: &T) -> Result { + rewrite_values(&quick_xml::se::to_string(value)?, true) +} + +// Serde sees an escaped string; the public XML API sees an XML fragment. +fn rewrite_values(xml: &str, writing: bool) -> Result { + let mut reader = quick_xml::Reader::from_str(xml); + let mut stack: Vec<(Vec, BTreeMap)> = Vec::new(); + let mut output = String::new(); + let mut copied = 0; + loop { + match reader.read_event().map_err(quick_xml::DeError::from)? { + Event::Start(ref start) => { + let name = start.local_name(); + let is_value = (name.as_ref() == b"Value" + && stack + .last() + .is_some_and(|(name, _)| name.as_slice() == b"XMLAnnotation")) + || (stack.is_empty() && name.as_ref() == b"XmlAnnotationValue"); + let mut namespaces = stack.last().map(|(_, ns)| ns.clone()).unwrap_or_default(); + for attribute in start.attributes() { + let attribute = attribute + .map_err(quick_xml::Error::from) + .map_err(quick_xml::DeError::from)?; + let key = attribute.key.as_ref(); + if key == b"xmlns" || key.starts_with(b"xmlns:") { + namespaces.insert( + String::from_utf8_lossy(key).into_owned(), + quick_xml::escape::escape( + attribute + .decoded_and_normalized_value( + quick_xml::XmlVersion::Implicit1_0, + reader.decoder(), + ) + .map_err(quick_xml::DeError::from)?, + ) + .into_owned(), + ); + } + } + if is_value { + let inner_start = reader.buffer_position() as usize; + let mut depth = 0; + let inner_end = loop { + let position = reader.buffer_position() as usize; + match reader.read_event().map_err(quick_xml::DeError::from)? { + Event::Start(_) => depth += 1, + Event::End(_) if depth == 0 => break position, + Event::End(_) => depth -= 1, + Event::Eof => return Err(quick_xml::DeError::UnexpectedEof.into()), + _ => {} + } + }; + output.push_str(&xml[copied..inner_start]); + let inner = &xml[inner_start..inner_end]; + if writing { + let fragment = quick_xml::escape::unescape(inner) + .map_err(|error| quick_xml::DeError::Custom(error.to_string()))?; + validate_fragment(&fragment)?; + output.push_str(&fragment); + } else { + let fragment = retain_namespaces(inner, &namespaces)?; + output.push_str(&quick_xml::escape::escape(fragment)); + } + copied = inner_end; + } else { + stack.push((name.as_ref().to_vec(), namespaces)); + } + } + Event::End(_) => { + stack.pop(); + } + Event::Eof => break, + _ => {} + } + } + output.push_str(&xml[copied..]); + Ok(output) +} + +fn validate_fragment(fragment: &str) -> Result<(), Error> { + let wrapped = format!("{fragment}"); + let mut reader = quick_xml::Reader::from_str(&wrapped); + let mut depth = 0; + loop { + match reader.read_event().map_err(quick_xml::DeError::from)? { + Event::Start(start) => { + for attribute in start.attributes() { + attribute + .map_err(quick_xml::Error::from) + .map_err(quick_xml::DeError::from)?; + } + depth += 1; + } + Event::Empty(start) => { + for attribute in start.attributes() { + attribute + .map_err(quick_xml::Error::from) + .map_err(quick_xml::DeError::from)?; + } + if depth == 0 { + return Err(quick_xml::DeError::Custom( + "XML fragment escapes its Value element".into(), + ) + .into()); + } + } + Event::End(_) => { + depth -= 1; + if depth == 0 && reader.buffer_position() as usize != wrapped.len() { + return Err(quick_xml::DeError::Custom( + "XML fragment escapes its Value element".into(), + ) + .into()); + } + } + Event::DocType(_) | Event::Decl(_) => { + return Err(quick_xml::DeError::Custom( + "XML declarations and DTDs are not allowed in annotation fragments".into(), + ) + .into()); + } + Event::Eof => break, + _ => {} + } + } + if depth != 0 { + return Err(quick_xml::DeError::UnexpectedEof.into()); + } + Ok(()) +} + +// Retain ancestor namespace bindings on top-level payload elements. +fn retain_namespaces( + fragment: &str, + namespaces: &BTreeMap, +) -> Result { + let mut reader = quick_xml::Reader::from_str(fragment); + let mut output = String::new(); + let mut copied = 0; + let mut depth = 0; + loop { + let before = reader.buffer_position() as usize; + let event = reader.read_event().map_err(quick_xml::DeError::from)?; + let is_start = matches!(event, Event::Start(_)); + match event { + Event::Start(ref start) | Event::Empty(ref start) if depth == 0 => { + let mut declared = Vec::new(); + for attribute in start.attributes() { + let attribute = attribute + .map_err(quick_xml::Error::from) + .map_err(quick_xml::DeError::from)?; + declared.push(String::from_utf8_lossy(attribute.key.as_ref()).into_owned()); + } + let insertion = before + 1 + start.name().as_ref().len(); + output.push_str(&fragment[copied..insertion]); + for (name, value) in namespaces { + if !declared.contains(name) + && (name == "xmlns" + || fragment + .contains(&format!("{}:", name.trim_start_matches("xmlns:")))) + { + output.push_str(&format!(" {name}=\"{value}\"")); + } + } + copied = insertion; + if is_start { + depth += 1; + } + } + Event::Start(_) => depth += 1, + Event::End(_) => depth -= 1, + Event::Eof => break, + _ => {} + } + } + output.push_str(&fragment[copied..]); + Ok(output) +}