1use 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}