- fix some discrepancies with the xsd

This commit is contained in:
w.pomp committed 2026-10-01 18:59:31 +02:00
1 parent 4dba00a004
commit 81bd302967
6 files changed
+317 -39

No files matched your search

+1 -1
View File
@@ -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 <w.pomp@nki.nl>"]
+2 -2
View File
@@ -10,11 +10,11 @@ pub fn ome_metadata_derive_xml(input: TokenStream) -> TokenStream {
let expanded = quote! {
impl #ident {
pub fn from_xml<T: AsRef<str>>(s: T) -> Result<Self, Error> {
Ok(from_str(s.as_ref())?)
from_str(s.as_ref())
}
pub fn to_xml(&self) -> Result<String, Error> {
Ok(to_string(self)?)
to_string(self)
}
}
};
+62
View File
@@ -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 & <software>")),
(
Some("urn:uuid:3e450fae-b8f2-4d35-aa54-702168b2487f"),
Some("example & <software>"),
),
] {
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("</OME>"));
assert_eq!(Ome::from_xml(&xml)?, ome);
}
Ok(())
}
test_read!(
a: "YTL1849A111_2023_05_04__14_46_19_cellnr_1_track"
b: "Experiment-2029"
+62 -23
View File
@@ -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<Microscope>,
#[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<LightSourceGroup>,
#[serde(default, rename = "Detector", skip_serializing_if = "Vec::is_empty")]
pub detector: Vec<Detector>,
@@ -3310,7 +3305,7 @@ pub enum NamingConventionType {
/// ```
/// use ome_metadata::Ome;
///
/// let xml = r#"<OME xmlns="https://www.openmicroscopy.org/Schemas/OME/2016-06" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://www.openmicroscopy.org/Schemas/OME/2016-06 https://www.openmicroscopy.org/Schemas/OME/2016-06/ome.xsd">
/// let xml = r#"<OME xmlns="http://www.openmicroscopy.org/Schemas/OME/2016-06" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openmicroscopy.org/Schemas/OME/2016-06 http://www.openmicroscopy.org/Schemas/OME/2016-06/ome.xsd">
/// <Image ID="Image:0" Name="test.tif">
/// <AcquisitionDate>2025-01-29T14:42:42</AcquisitionDate>
/// <Description/>
@@ -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<T: AsRef<str>>(s: T) -> Result<Self, Error> {
Ok(from_str(s.as_ref())?)
from_str(s.as_ref())
}
pub fn to_xml(&self) -> Result<String, Error> {
Ok(to_string(self)?.replace("<Ome>", r###"<OME xmlns="https://www.openmicroscopy.org/Schemas/OME/2016-06" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://www.openmicroscopy.org/Schemas/OME/2016-06 https://www.openmicroscopy.org/Schemas/OME/2016-06/ome.xsd">"###).replace("</Ome>", "</OME>"))
Ok(to_string(self)?.replacen(
"<OME",
r#"<OME xmlns="http://www.openmicroscopy.org/Schemas/OME/2016-06" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openmicroscopy.org/Schemas/OME/2016-06 http://www.openmicroscopy.org/Schemas/OME/2016-06/ome.xsd""#,
1,
))
}
}
#[cfg_attr(
@@ -3943,8 +3943,12 @@ pub struct Plane {
pub position_z_unit: UnitsLength,
#[serde(rename = "HashSHA1", skip_serializing_if = "Option::is_none")]
pub hash_sha1: Option<String>,
#[serde(rename = "AnnotationRef", skip_serializing_if = "Option::is_none")]
pub annotation_ref: Option<AnnotationRef>,
#[serde(
default,
rename = "AnnotationRef",
skip_serializing_if = "Vec::is_empty"
)]
pub annotation_ref: Vec<AnnotationRef>,
}
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<String>,
#[serde(rename = "Union", skip_serializing_if = "Option::is_none")]
pub union: Option<RoiUnion>,
#[serde(rename = "AnnotationRef", skip_serializing_if = "Option::is_none")]
pub annotation_ref: Option<AnnotationRef>,
#[serde(
default,
rename = "AnnotationRef",
skip_serializing_if = "Vec::is_empty"
)]
pub annotation_ref: Vec<AnnotationRef>,
#[serde(rename = "Description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
@@ -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<ShapeGroup>,
}
#[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<StructuredAnnotationsContent>,
#[serde(default, rename = "$value", skip_serializing_if = "Vec::is_empty")]
pub content: Vec<StructuredAnnotationsContent>,
}
#[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<AnnotationRef>,
}
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<f64, Error>;
+1 -13
View File
@@ -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<Self, Self::Error> {
Ok(Self {})
}
}
impl PyDisplay for XmlAnnotationValue {
fn py_str(&self) -> String {
"XmlAnnotationValue".to_string()
}
}
impl PyDisplay for ExperimentType {
fn py_str(&self) -> String {
format!(
+189
View File
@@ -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<T: DeserializeOwned>(xml: &str) -> Result<T, Error> {
Ok(quick_xml::de::from_str(&rewrite_values(xml, false)?)?)
}
pub(crate) fn to_string<T: Serialize>(value: &T) -> Result<String, Error> {
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<String, Error> {
let mut reader = quick_xml::Reader::from_str(xml);
let mut stack: Vec<(Vec<u8>, BTreeMap<String, String>)> = 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!("<Value>{fragment}</Value>");
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<String, String>,
) -> Result<String, Error> {
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)
}