1use std::collections::HashMap;
28
29use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CmlError {
38 UnknownElement(String),
40 UnknownAtomRef(String),
42 InvalidAtomRefs2(String),
44 InvalidBondOrder(String),
46}
47
48impl std::fmt::Display for CmlError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 CmlError::UnknownElement(s) => write!(f, "unknown element symbol: {s}"),
52 CmlError::UnknownAtomRef(s) => write!(f, "unknown atom ref: {s}"),
53 CmlError::InvalidAtomRefs2(s) => write!(f, "invalid atomRefs2: {s}"),
54 CmlError::InvalidBondOrder(s) => write!(f, "invalid bond order: {s}"),
55 }
56 }
57}
58
59impl std::error::Error for CmlError {}
60
61#[doc(hidden)]
67pub(crate) fn parse_xml_attrs(line: &str) -> HashMap<String, String> {
73 let mut map = HashMap::new();
74 let bytes = line.as_bytes();
75 let len = bytes.len();
76 let mut i = 0usize;
77
78 while i < len {
79 while i < len && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/' || bytes[i] == b'>') {
81 i += 1;
82 }
83 if i >= len {
84 break;
85 }
86
87 let key_start = i;
89 while i < len && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
90 i += 1;
91 }
92 let key = line[key_start..i].trim().to_string();
93 if key.is_empty() {
94 i += 1;
95 continue;
96 }
97
98 while i < len && bytes[i].is_ascii_whitespace() {
100 i += 1;
101 }
102 if i >= len || bytes[i] != b'=' {
103 continue;
104 }
105 i += 1;
106
107 while i < len && bytes[i].is_ascii_whitespace() {
109 i += 1;
110 }
111 if i >= len {
112 break;
113 }
114 let quote = bytes[i];
115 if quote != b'"' && quote != b'\'' {
116 i += 1;
117 continue;
118 }
119 i += 1;
120
121 let value_start = i;
123 while i < len && bytes[i] != quote {
124 i += 1;
125 }
126 let raw_value = &line[value_start..i];
127 let value = unescape_xml(raw_value);
128 if i < len {
129 i += 1;
130 } if !key.is_empty() {
133 map.insert(key, value);
134 }
135 }
136 map
137}
138
139fn unescape_xml(s: &str) -> String {
141 if !s.contains('&') {
142 return s.to_string();
143 }
144 s.replace("&", "&")
145 .replace("<", "<")
146 .replace(">", ">")
147 .replace(""", "\"")
148 .replace("'", "'")
149}
150
151fn parse_bond_order(s: &str) -> Result<BondOrder, CmlError> {
156 match s.trim() {
157 "0" | "zero" => Ok(BondOrder::Zero),
158 "1" | "S" | "single" => Ok(BondOrder::Single),
159 "2" | "D" | "double" => Ok(BondOrder::Double),
160 "3" | "T" | "triple" => Ok(BondOrder::Triple),
161 "A" | "aromatic" => Ok(BondOrder::Aromatic),
163 "any" => Ok(BondOrder::QueryAny),
164 "S/D" => Ok(BondOrder::QuerySingleOrDouble),
165 "S/A" => Ok(BondOrder::QuerySingleOrAromatic),
166 "D/A" => Ok(BondOrder::QueryDoubleOrAromatic),
167 other => Err(CmlError::InvalidBondOrder(other.to_string())),
168 }
169}
170
171struct CmlAtomData {
177 element: Element,
178 charge: i8,
179 isotope: Option<u16>,
180 hydrogen_count: Option<u8>,
181 x2: f64,
182 y2: f64,
183 aromatic: bool,
184}
185
186pub fn parse_cml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CmlError> {
210 let mut atom_data: Vec<CmlAtomData> = Vec::new();
213 let mut atom_id_to_pos: HashMap<String, usize> = HashMap::new();
214
215 struct BondData {
216 a1: usize,
217 a2: usize,
218 order: BondOrder,
219 aromatic: bool,
220 }
221 let mut bond_data: Vec<BondData> = Vec::new();
222
223 for raw_line in input.lines() {
224 let line = raw_line.trim();
225 if line.is_empty() {
226 continue;
227 }
228
229 if is_element_tag(line, "atom") {
230 let attrs = parse_xml_attrs(line);
231 let id = attrs.get("id").cloned().unwrap_or_default();
232
233 let element_sym = attrs.get("elementType").map(String::as_str).unwrap_or("C");
234 let element = Element::from_symbol(element_sym)
235 .ok_or_else(|| CmlError::UnknownElement(element_sym.to_string()))?;
236
237 let charge = attrs
238 .get("formalCharge")
239 .and_then(|s| s.trim().parse::<i8>().ok())
240 .unwrap_or(0);
241 let isotope = attrs
242 .get("isotope")
243 .or_else(|| attrs.get("isotopeNumber"))
244 .and_then(|s| s.trim().parse::<u16>().ok())
245 .filter(|&v| v > 0);
246 let hydrogen_count = attrs
247 .get("hydrogenCount")
248 .and_then(|s| s.trim().parse::<u8>().ok());
249 let x2 = attrs
250 .get("x2")
251 .and_then(|s| s.trim().parse::<f64>().ok())
252 .unwrap_or(0.0);
253 let y2 = attrs
254 .get("y2")
255 .and_then(|s| s.trim().parse::<f64>().ok())
256 .unwrap_or(0.0);
257
258 let pos = atom_data.len();
259 if !id.is_empty() {
260 atom_id_to_pos.insert(id.clone(), pos);
261 }
262 atom_data.push(CmlAtomData {
263 element,
264 charge,
265 isotope,
266 hydrogen_count,
267 x2,
268 y2,
269 aromatic: false,
270 });
271 continue;
272 }
273
274 if is_element_tag(line, "bond") {
275 let attrs = parse_xml_attrs(line);
276 let refs = attrs
277 .get("atomRefs2")
278 .ok_or_else(|| CmlError::InvalidAtomRefs2("missing atomRefs2".to_string()))?;
279 let parts: Vec<&str> = refs.split_whitespace().collect();
280 if parts.len() < 2 {
281 return Err(CmlError::InvalidAtomRefs2(refs.clone()));
282 }
283 let a1 = *atom_id_to_pos
284 .get(parts[0])
285 .ok_or_else(|| CmlError::UnknownAtomRef(parts[0].to_string()))?;
286 let a2 = *atom_id_to_pos
287 .get(parts[1])
288 .ok_or_else(|| CmlError::UnknownAtomRef(parts[1].to_string()))?;
289 let order_s = attrs.get("order").map(String::as_str).unwrap_or("1");
290 let is_aromatic = matches!(order_s.trim(), "A" | "aromatic");
291 let order = parse_bond_order(order_s)?;
292 bond_data.push(BondData {
293 a1,
294 a2,
295 order,
296 aromatic: is_aromatic,
297 });
298 }
299 }
300
301 for bd in &bond_data {
304 if bd.aromatic {
305 if let Some(a) = atom_data.get_mut(bd.a1) {
306 a.aromatic = true;
307 }
308 if let Some(a) = atom_data.get_mut(bd.a2) {
309 a.aromatic = true;
310 }
311 }
312 }
313
314 let mut builder = MoleculeBuilder::new();
315 let mut idx_by_pos: Vec<AtomIdx> = Vec::new();
316 let mut coords: Vec<(f64, f64)> = Vec::new();
317
318 for ad in &atom_data {
319 let mut atom = Atom::new(ad.element);
320 atom.charge = ad.charge;
321 atom.isotope = ad.isotope;
322 atom.hydrogen_count = ad.hydrogen_count;
323 atom.aromatic = ad.aromatic;
324 let idx = builder.add_atom(atom);
325 idx_by_pos.push(idx);
326 coords.push((ad.x2, ad.y2));
327 }
328
329 for bd in &bond_data {
330 let a1 = idx_by_pos[bd.a1];
331 let a2 = idx_by_pos[bd.a2];
332 builder
333 .add_bond(a1, a2, bd.order)
334 .map_err(|_| CmlError::InvalidAtomRefs2(format!("{} {}", bd.a1, bd.a2)))?;
335 }
336
337 Ok((builder.build(), coords))
338}
339
340fn is_element_tag(line: &str, name: &str) -> bool {
343 let check = |prefix: &str| -> bool {
344 line.starts_with(prefix) && {
345 let rest = &line[prefix.len()..];
346 rest.is_empty()
347 || rest.starts_with(' ')
348 || rest.starts_with('/')
349 || rest.starts_with('>')
350 || rest.starts_with('\t')
351 }
352 };
353 check(&format!("<{name}")) || check(&format!("<{}", name.to_uppercase()))
354}
355
356pub fn write_cml(mol: &Molecule, coords: Option<&[(f64, f64)]>) -> String {
365 let mut out = String::from(
366 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
367 <molecule xmlns=\"http://www.xml-cml.org/schema\">\n <atomArray>\n",
368 );
369
370 for (i, (idx, atom)) in mol.atoms().enumerate() {
371 let sym = atom.element.symbol();
372 let mut parts = vec![
373 format!("id=\"a{}\"", idx.0 + 1),
374 format!("elementType=\"{sym}\""),
375 ];
376 if let Some(cs) = coords
377 && let Some(&(x, y)) = cs.get(i)
378 {
379 parts.push(format!("x2=\"{x:.4}\""));
380 parts.push(format!("y2=\"{y:.4}\""));
381 }
382 if atom.charge != 0 {
383 parts.push(format!("formalCharge=\"{}\"", atom.charge));
384 }
385 if let Some(iso) = atom.isotope {
386 parts.push(format!("isotopeNumber=\"{iso}\""));
387 }
388 if let Some(h) = atom.hydrogen_count {
389 parts.push(format!("hydrogenCount=\"{h}\""));
390 }
391 out.push_str(&format!(" <atom {}/>\n", parts.join(" ")));
392 }
393
394 out.push_str(" </atomArray>\n <bondArray>\n");
395
396 for (_, bond) in mol.bonds() {
397 let order_str = match bond.order {
398 BondOrder::Zero => "0",
399 BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => "1",
400 BondOrder::Double => "2",
401 BondOrder::Triple => "3",
402 BondOrder::Aromatic => "A",
403 BondOrder::Quadruple => "4",
404 BondOrder::QueryAny => "any",
405 BondOrder::QuerySingleOrDouble => "S/D",
406 BondOrder::QuerySingleOrAromatic => "S/A",
407 BondOrder::QueryDoubleOrAromatic => "D/A",
408 };
409 let a1_id = bond.atom1.0 + 1;
410 let a2_id = bond.atom2.0 + 1;
411 out.push_str(&format!(
412 " <bond atomRefs2=\"a{a1_id} a{a2_id}\" order=\"{order_str}\"/>\n"
413 ));
414 }
415
416 out.push_str(" </bondArray>\n</molecule>\n");
417 out
418}
419
420#[cfg(test)]
425mod tests {
426 use super::*;
427
428 const ETHANOL_CML: &str = r#"<?xml version="1.0"?>
429<molecule xmlns="http://www.xml-cml.org/schema">
430 <atomArray>
431 <atom id="a1" elementType="C" x2="0.0" y2="0.0"/>
432 <atom id="a2" elementType="C" x2="1.5" y2="0.0"/>
433 <atom id="a3" elementType="O" x2="3.0" y2="0.0"/>
434 </atomArray>
435 <bondArray>
436 <bond atomRefs2="a1 a2" order="1"/>
437 <bond atomRefs2="a2 a3" order="1"/>
438 </bondArray>
439</molecule>"#;
440
441 #[test]
442 fn parse_cml_ethanol_atom_count() {
443 let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
444 assert_eq!(mol.atom_count(), 3, "ethanol has 3 heavy atoms");
445 assert_eq!(mol.bond_count(), 2, "ethanol has 2 bonds");
446 assert_eq!(coords.len(), 3, "one coord per atom");
447 }
448
449 #[test]
450 fn parse_cml_ethanol_elements() {
451 let (mol, _) = parse_cml(ETHANOL_CML).unwrap();
452 let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
453 assert!(elems.contains(&"C"), "should have C");
454 assert!(elems.contains(&"O"), "should have O");
455 }
456
457 #[test]
458 fn parse_cml_ethanol_coords() {
459 let (_, coords) = parse_cml(ETHANOL_CML).unwrap();
460 assert!((coords[0].0 - 0.0).abs() < 0.001, "a1 x=0.0");
461 assert!((coords[1].0 - 1.5).abs() < 0.001, "a2 x=1.5");
462 assert!((coords[2].0 - 3.0).abs() < 0.001, "a3 x=3.0");
463 }
464
465 #[test]
466 fn parse_cml_formal_charge() {
467 let cml = r#"<molecule>
468 <atomArray>
469 <atom id="a1" elementType="N" formalCharge="1"/>
470 </atomArray>
471 <bondArray/>
472</molecule>"#;
473 let (mol, _) = parse_cml(cml).unwrap();
474 let atom = mol.atom(chematic_core::AtomIdx(0));
475 assert_eq!(atom.charge, 1, "N+ should have charge=1");
476 }
477
478 #[test]
479 fn parse_cml_unknown_element_returns_err() {
480 let cml = r#"<molecule>
481 <atomArray>
482 <atom id="a1" elementType="Xx"/>
483 </atomArray>
484</molecule>"#;
485 let result = parse_cml(cml);
486 assert!(
487 matches!(result, Err(CmlError::UnknownElement(_))),
488 "unknown element should return Err"
489 );
490 }
491
492 #[test]
493 fn write_cml_roundtrip() {
494 let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
495 let written = write_cml(&mol, Some(&coords));
496 let (mol2, coords2) = parse_cml(&written).unwrap();
497
498 assert_eq!(mol.atom_count(), mol2.atom_count(), "atom count preserved");
499 assert_eq!(mol.bond_count(), mol2.bond_count(), "bond count preserved");
500 assert!(
501 (coords[1].0 - coords2[1].0).abs() < 0.001,
502 "x coord preserved"
503 );
504 }
505
506 #[test]
507 fn write_cml_no_coords() {
508 use chematic_core::MoleculeBuilder;
509 let mut b = MoleculeBuilder::new();
510 b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
511 b.add_atom(Atom::new(Element::from_symbol("O").unwrap()));
512 let mol = b.build();
513 let cml = write_cml(&mol, None);
514 assert!(cml.contains("elementType=\"C\""), "C atom in output");
515 assert!(cml.contains("elementType=\"O\""), "O atom in output");
516 assert!(!cml.contains("x2="), "no x2 when no coords");
517 }
518
519 #[test]
520 fn parse_cml_double_bond() {
521 let cml = r#"<molecule>
522 <atomArray>
523 <atom id="a1" elementType="C"/>
524 <atom id="a2" elementType="O"/>
525 </atomArray>
526 <bondArray>
527 <bond atomRefs2="a1 a2" order="2"/>
528 </bondArray>
529</molecule>"#;
530 let (mol, _) = parse_cml(cml).unwrap();
531 let bond = mol.bond(chematic_core::BondIdx(0));
532 assert_eq!(bond.order, BondOrder::Double, "order=2 should give Double");
533 }
534}