Skip to main content

document_svg/document/
cml.rs

1//! Bounded Chemical Markup Language (CML) previews.
2//!
3//! CML represents molecules, reactions, spectra and related chemistry data in
4//! XML. This adapter exposes molecule/atom/bond structure and element counts
5//! without resolving dictionaries, conventions, URLs or performing chemistry.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_CML_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_CML_XML_EVENTS: usize = 1_000_000;
18const MAX_CML_XML_NODES: usize = 500_000;
19const MAX_CML_XML_DEPTH: usize = 128;
20const MAX_CML_TEXT_BYTES: usize = 48 * 1024 * 1024;
21const MAX_CML_ROWS: usize = 100_000;
22const MAX_CML_DISPLAY_BYTES: usize = 512;
23const CML_NAMESPACE: &str = "http://www.xml-cml.org/schema";
24
25pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
26    crate::geospatial::xml_tree::looks_like_root(bytes, b"cml", None)
27        && String::from_utf8_lossy(bytes)
28            .to_ascii_lowercase()
29            .contains("xml-cml.org/schema")
30}
31
32struct CmlPageSink<'a> {
33    inner: &'a mut dyn PageConsumer,
34    warnings: &'a [String],
35}
36impl PageConsumer for CmlPageSink<'_> {
37    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
38        page.source_format = "cml".into();
39        if page.title.is_empty() {
40            page.title = "CML chemical document".into();
41        }
42        page.description = "CML molecule/reaction metadata is rendered as a bounded inert summary; dictionaries, URLs and chemistry operations are not resolved".into();
43        for warning in self.warnings {
44            page.warn(warning.clone());
45        }
46        self.inner.consume(page)
47    }
48}
49
50#[derive(Default)]
51struct Summary {
52    molecules: usize,
53    atoms: usize,
54    bonds: usize,
55    reactions: usize,
56    spectra: usize,
57    properties: usize,
58    elements: BTreeMap<String, usize>,
59    rows: Vec<Vec<String>>,
60}
61
62pub(crate) fn convert(
63    path: &Path,
64    options: &ConvertOptions,
65    sink: &mut dyn PageConsumer,
66) -> Result<Vec<String>> {
67    let bytes = read_limited_file(
68        path,
69        options.max_input_bytes.min(MAX_CML_BYTES),
70        "CML input",
71    )?;
72    let root = parse_xml_tree(
73        &bytes,
74        &XmlLimits {
75            max_events: options.max_xml_events.min(MAX_CML_XML_EVENTS),
76            max_nodes: MAX_CML_XML_NODES,
77            max_depth: MAX_CML_XML_DEPTH,
78            max_text_bytes: MAX_CML_TEXT_BYTES,
79        },
80        "CML",
81    )?;
82    if !root.name.eq_ignore_ascii_case("cml") {
83        return Err(Error::InvalidInput("CML root must cml".into()));
84    }
85    if root
86        .namespace
87        .as_deref()
88        .is_none_or(|namespace| namespace != CML_NAMESPACE)
89    {
90        return Err(Error::InvalidInput(
91            "CML namespace is missing or unsupported".into(),
92        ));
93    }
94    let mut summary = Summary {
95        molecules: count_named(&root, "molecule"),
96        atoms: count_named(&root, "atom"),
97        bonds: count_named(&root, "bond"),
98        reactions: count_named(&root, "reaction"),
99        spectra: count_named(&root, "spectrum"),
100        properties: count_named(&root, "property"),
101        ..Summary::default()
102    };
103    for atom in descendants_named(&root, "atom") {
104        if let Some(element) = atom.attribute("elementType") {
105            *summary.elements.entry(element.to_owned()).or_default() += 1;
106        }
107    }
108    for molecule in descendants_named(&root, "molecule")
109        .into_iter()
110        .take(MAX_CML_ROWS)
111    {
112        let name = molecule
113            .attribute("title")
114            .or_else(|| molecule.attribute("id"))
115            .unwrap_or("[unnamed molecule]");
116        let atoms = count_named(molecule, "atom");
117        let bonds = count_named(molecule, "bond");
118        push_row(
119            &mut summary.rows,
120            "Molecule",
121            name,
122            &format!("atoms={atoms} bonds={bonds}"),
123        )?;
124    }
125    if summary.molecules == 0 && summary.reactions == 0 && summary.spectra == 0 {
126        return Err(Error::InvalidInput(
127            "CML document contains no molecule, reaction or spectrum structure".into(),
128        ));
129    }
130    let distribution = summary
131        .elements
132        .iter()
133        .map(|(element, count)| format!("{element}={count}"))
134        .collect::<Vec<_>>()
135        .join(" ");
136    push_row(
137        &mut summary.rows,
138        "Document",
139        "CML",
140        &format!(
141            "molecules={} reactions={} spectra={}",
142            summary.molecules, summary.reactions, summary.spectra
143        ),
144    )?;
145    push_row(
146        &mut summary.rows,
147        "Atoms/bonds",
148        &format!("{}/{}", summary.atoms, summary.bonds),
149        "coordinates and bond payloads omitted",
150    )?;
151    push_row(
152        &mut summary.rows,
153        "Elements",
154        &distribution,
155        "elementType counts",
156    )?;
157    push_row(
158        &mut summary.rows,
159        "Properties",
160        &summary.properties.to_string(),
161        "values/dictionaries omitted",
162    )?;
163    let metadata = format!(
164        "Molecules: {}\nAtoms: {}\nBonds: {}\nReactions: {}\nSpectra: {}\nProperties: {}",
165        summary.molecules,
166        summary.atoms,
167        summary.bonds,
168        summary.reactions,
169        summary.spectra,
170        summary.properties
171    );
172    let blocks = vec![
173        HtmlBlock::Heading {
174            level: 1,
175            text: "CML chemical document".into(),
176        },
177        HtmlBlock::Paragraph { text: metadata },
178        HtmlBlock::Table(TableData {
179            headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
180            rows: summary.rows,
181            alignments: vec![TableAlign::Left; 3],
182            raw_source: String::new(),
183        }),
184    ];
185    let warnings = vec![
186        "CML molecule/reaction/spectrum structure, atom/bond counts and element distribution are shown; coordinates, charges, dictionaries, conventions, property values and URLs are omitted or redacted".into(),
187        "CML XML traversal and rows are bounded; DTD/entities, external dictionaries/resources, reaction evaluation, geometry, valence repair and chemical calculation never run".into(),
188    ];
189    let mut page_sink = CmlPageSink {
190        inner: sink,
191        warnings: &warnings,
192    };
193    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
194    Ok(warnings)
195}
196
197fn count_named(element: &XmlElement, name: &str) -> usize {
198    element
199        .children
200        .iter()
201        .map(|child| usize::from(child.name.eq_ignore_ascii_case(name)) + count_named(child, name))
202        .sum()
203}
204fn descendants_named<'a>(element: &'a XmlElement, name: &str) -> Vec<&'a XmlElement> {
205    let mut result = Vec::new();
206    for child in &element.children {
207        if child.name.eq_ignore_ascii_case(name) {
208            result.push(child);
209        }
210        result.extend(descendants_named(child, name));
211    }
212    result
213}
214fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
215    if rows.len() >= MAX_CML_ROWS {
216        return Err(Error::LimitExceeded(format!(
217            "CML rows exceed {MAX_CML_ROWS}"
218        )));
219    }
220    rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
221    Ok(())
222}
223fn truncate(value: &str) -> String {
224    if value.len() <= MAX_CML_DISPLAY_BYTES {
225        value.to_owned()
226    } else {
227        let mut end = MAX_CML_DISPLAY_BYTES;
228        while !value.is_char_boundary(end) {
229            end -= 1;
230        }
231        format!("{}…", &value[..end])
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::looks_like_prefix;
238    #[test]
239    fn recognizes_cml_namespace() {
240        assert!(looks_like_prefix(
241            br#"<cml xmlns="http://www.xml-cml.org/schema"><molecule/></cml>"#
242        ));
243    }
244    #[test]
245    fn rejects_generic_cml() {
246        assert!(!looks_like_prefix(br#"<cml><molecule/></cml>"#));
247    }
248}