1use std::collections::HashMap;
27
28use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
29use chematic_perception::assign_ez_from_2d;
30
31use crate::cml::parse_xml_attrs;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum CdxmlError {
40 UnknownAtomicNumber(u32),
42 UnknownAtomRef(String),
44 MissingBondEndpoint,
46 InvalidCoords(String),
48 TooManyAtoms(usize),
50}
51
52impl std::fmt::Display for CdxmlError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 CdxmlError::UnknownAtomicNumber(n) => write!(f, "unknown atomic number: {n}"),
56 CdxmlError::UnknownAtomRef(s) => write!(f, "unknown atom ref: {s}"),
57 CdxmlError::MissingBondEndpoint => write!(f, "bond missing B or E attribute"),
58 CdxmlError::InvalidCoords(s) => write!(f, "invalid p coords: {s}"),
59 CdxmlError::TooManyAtoms(n) => write!(f, "CDXML document exceeds atom limit ({n})"),
60 }
61 }
62}
63
64impl std::error::Error for CdxmlError {}
65
66pub fn parse_cdxml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CdxmlError> {
79 let mut all = parse_cdxml_all(input)?;
80 if all.is_empty() {
81 return Ok((MoleculeBuilder::new().build(), vec![]));
82 }
83 Ok(all.remove(0))
84}
85
86#[derive(Default)]
99struct FragAccum {
100 atom_ids: Vec<String>,
101 atom_elems: Vec<Element>,
102 atom_charges: Vec<i8>,
103 atom_isotopes: Vec<Option<u16>>,
104 atom_h: Vec<Option<u8>>,
105 atom_xs: Vec<f64>,
106 atom_ys: Vec<f64>,
107 bond_bs: Vec<String>,
108 bond_es: Vec<String>,
109 bond_ords: Vec<BondOrder>,
110}
111
112impl FragAccum {
113 fn is_empty(&self) -> bool {
114 self.atom_ids.is_empty() && self.bond_bs.is_empty()
115 }
116
117 fn flush(&mut self, results: &mut Vec<(Molecule, Vec<(f64, f64)>)>) -> Result<(), CdxmlError> {
118 if self.is_empty() {
119 return Ok(());
120 }
121
122 let mut id_to_pos: HashMap<&str, usize> = HashMap::new();
123 for (i, id) in self.atom_ids.iter().enumerate() {
124 id_to_pos.insert(id.as_str(), i);
125 }
126
127 let mut builder = MoleculeBuilder::new();
128 let mut idx_map: HashMap<usize, AtomIdx> = HashMap::new();
129 let mut coords: Vec<(f64, f64)> = Vec::new();
130
131 for i in 0..self.atom_ids.len() {
132 let mut a = Atom::new(self.atom_elems[i]);
133 a.charge = self.atom_charges[i];
134 a.isotope = self.atom_isotopes[i];
135 a.hydrogen_count = self.atom_h[i];
136 let new_idx = builder.add_atom(a);
137 idx_map.insert(i, new_idx);
138 coords.push((self.atom_xs[i], self.atom_ys[i]));
139 }
140
141 for k in 0..self.bond_bs.len() {
142 let pos_b = *id_to_pos
143 .get(self.bond_bs[k].as_str())
144 .ok_or_else(|| CdxmlError::UnknownAtomRef(self.bond_bs[k].clone()))?;
145 let pos_e = *id_to_pos
146 .get(self.bond_es[k].as_str())
147 .ok_or_else(|| CdxmlError::UnknownAtomRef(self.bond_es[k].clone()))?;
148 let a1 = idx_map[&pos_b];
149 let a2 = idx_map[&pos_e];
150 builder.add_bond(a1, a2, self.bond_ords[k]).map_err(|_| {
151 CdxmlError::UnknownAtomRef(format!("{} {}", self.bond_bs[k], self.bond_es[k]))
152 })?;
153 }
154
155 let mut mol = builder.build();
156 assign_ez_from_2d(&mut mol, &coords);
158 results.push((mol, coords));
159 *self = FragAccum::default();
160 Ok(())
161 }
162}
163
164pub const CDXML_MAX_ATOMS: usize = 10_000;
166
167#[allow(clippy::type_complexity)]
168pub fn parse_cdxml_all(input: &str) -> Result<Vec<(Molecule, Vec<(f64, f64)>)>, CdxmlError> {
169 let mut acc = FragAccum::default();
170 let mut results: Vec<(Molecule, Vec<(f64, f64)>)> = Vec::new();
171
172 for raw_line in input.lines() {
173 let line = raw_line.trim();
174 if line.is_empty() {
175 continue;
176 }
177
178 if line.starts_with("<fragment") {
179 acc = FragAccum::default();
180 continue;
181 }
182
183 if line.starts_with("</fragment>") {
184 acc.flush(&mut results)?;
185 continue;
186 }
187
188 if is_n_tag(line) {
189 let attrs = parse_xml_attrs(line);
190 let id = match attrs.get("id") {
191 Some(s) => s.clone(),
192 None => continue,
193 };
194 let element_num: u32 = attrs
195 .get("Element")
196 .and_then(|s| s.trim().parse().ok())
197 .unwrap_or(6);
198 if element_num > 255 {
199 return Err(CdxmlError::UnknownAtomicNumber(element_num));
200 }
201 let element = Element::from_atomic_number(element_num as u8)
202 .ok_or(CdxmlError::UnknownAtomicNumber(element_num))?;
203 let charge = attrs
204 .get("Charge")
205 .and_then(|s| s.trim().parse().ok())
206 .unwrap_or(0);
207 let isotope = attrs
208 .get("Isotope")
209 .and_then(|s| s.trim().parse::<u16>().ok())
210 .filter(|&v| v > 0);
211 let hcount = attrs
212 .get("NumHydrogens")
213 .and_then(|s| s.trim().parse().ok());
214 let (x, y) = if let Some(p) = attrs.get("p") {
215 let parts: Vec<&str> = p.split_whitespace().collect();
216 if parts.len() < 2 {
217 return Err(CdxmlError::InvalidCoords(p.clone()));
218 }
219 (
220 parts[0].parse().unwrap_or(0.0),
221 parts[1].parse().unwrap_or(0.0),
222 )
223 } else {
224 (0.0, 0.0)
225 };
226 if acc.atom_ids.len() >= CDXML_MAX_ATOMS {
227 return Err(CdxmlError::TooManyAtoms(CDXML_MAX_ATOMS));
228 }
229 acc.atom_ids.push(id);
230 acc.atom_elems.push(element);
231 acc.atom_charges.push(charge);
232 acc.atom_isotopes.push(isotope);
233 acc.atom_h.push(hcount);
234 acc.atom_xs.push(x);
235 acc.atom_ys.push(y);
236 continue;
237 }
238
239 if is_b_tag(line) {
240 let attrs = parse_xml_attrs(line);
241 let b = attrs
242 .get("B")
243 .cloned()
244 .ok_or(CdxmlError::MissingBondEndpoint)?;
245 let e = attrs
246 .get("E")
247 .cloned()
248 .ok_or(CdxmlError::MissingBondEndpoint)?;
249 let base: BondOrder = match attrs.get("Order").map(String::as_str) {
250 Some("2") => BondOrder::Double,
251 Some("3") => BondOrder::Triple,
252 Some("1.5") => BondOrder::Aromatic,
256 _ => BondOrder::Single,
257 };
258 let order = if base == BondOrder::Single {
259 match attrs.get("Display").map(String::as_str) {
260 Some("WedgeBegin") | Some("WedgedHashBegin") => BondOrder::Up,
261 Some("Hash") | Some("Dash") | Some("WedgeEnd") | Some("WedgedHashEnd") => {
262 BondOrder::Down
263 }
264 _ => BondOrder::Single,
265 }
266 } else {
267 base
268 };
269 acc.bond_bs.push(b);
270 acc.bond_es.push(e);
271 acc.bond_ords.push(order);
272 }
273 }
274
275 acc.flush(&mut results)?;
277
278 Ok(results)
279}
280
281fn is_n_tag(line: &str) -> bool {
283 (line.starts_with("<n ") || line.starts_with("<n\t") || line == "<n>")
284 && !line.starts_with("<node") }
286
287fn is_b_tag(line: &str) -> bool {
289 line.starts_with("<b ")
290 || line.starts_with("<b\t")
291 || line == "<b>"
292 || line.starts_with("<b/>")
293 || line.starts_with("<b>")
294}
295
296#[cfg(test)]
301mod tests {
302 use super::*;
303
304 const ETHANOL_CDXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
306<!DOCTYPE CDXML SYSTEM "http://www.cambridgesoft.com/xml/cdxml.dtd">
307<CDXML>
308<fragment>
309<n id="1" p="10.0 20.0" Element="6" NumHydrogens="3"/>
310<n id="2" p="25.0 20.0" Element="6" NumHydrogens="2"/>
311<n id="3" p="40.0 20.0" Element="8" NumHydrogens="1"/>
312<b B="1" E="2" Order="1"/>
313<b B="2" E="3" Order="1"/>
314</fragment>
315</CDXML>"#;
316
317 #[test]
318 fn parse_cdxml_ethanol_atom_count() {
319 let (mol, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
320 assert_eq!(mol.atom_count(), 3, "ethanol: 3 heavy atoms");
321 assert_eq!(mol.bond_count(), 2, "ethanol: 2 bonds");
322 assert_eq!(coords.len(), 3, "one coord per atom");
323 }
324
325 #[test]
326 fn parse_cdxml_ethanol_elements() {
327 let (mol, _) = parse_cdxml(ETHANOL_CDXML).unwrap();
328 let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
329 assert!(elems.contains(&"C"), "should contain C");
330 assert!(elems.contains(&"O"), "should contain O");
331 }
332
333 #[test]
334 fn parse_cdxml_ethanol_coords() {
335 let (_, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
336 assert!(
338 (coords[0].0 - 10.0).abs() < 0.01,
339 "first atom x=10.0: {:?}",
340 coords[0]
341 );
342 assert!(
343 (coords[0].1 - 20.0).abs() < 0.01,
344 "first atom y=20.0: {:?}",
345 coords[0]
346 );
347 }
348
349 #[test]
350 fn parse_cdxml_carbon_element_6() {
351 let cdxml = r#"<CDXML><fragment>
352<n id="1" Element="6" p="0 0"/>
353</fragment></CDXML>"#;
354 let (mol, _) = parse_cdxml(cdxml).unwrap();
355 assert_eq!(mol.atom_count(), 1);
356 let atom = mol.atom(chematic_core::AtomIdx(0));
357 assert_eq!(atom.element.symbol(), "C", "Element=6 → Carbon");
358 }
359
360 #[test]
361 fn parse_cdxml_double_bond() {
362 let cdxml = r#"<CDXML><fragment>
363<n id="1" Element="6" p="0 0"/>
364<n id="2" Element="8" p="10 0"/>
365<b B="1" E="2" Order="2"/>
366</fragment></CDXML>"#;
367 let (mol, _) = parse_cdxml(cdxml).unwrap();
368 let bond = mol.bond(chematic_core::BondIdx(0));
369 assert_eq!(bond.order, BondOrder::Double, "Order=2 → Double");
370 }
371
372 #[test]
373 fn parse_cdxml_charge() {
374 let cdxml = r#"<CDXML><fragment>
375<n id="1" Element="7" Charge="1" p="0 0"/>
376</fragment></CDXML>"#;
377 let (mol, _) = parse_cdxml(cdxml).unwrap();
378 let atom = mol.atom(chematic_core::AtomIdx(0));
379 assert_eq!(atom.charge, 1, "Charge=1 → N+");
380 }
381
382 #[test]
383 fn parse_cdxml_unknown_atomic_number_returns_err() {
384 let cdxml = r#"<CDXML><fragment>
385<n id="1" Element="999" p="0 0"/>
386</fragment></CDXML>"#;
387 let result = parse_cdxml(cdxml);
388 assert!(
389 matches!(result, Err(CdxmlError::UnknownAtomicNumber(_))),
390 "unknown atomic number should return Err"
391 );
392 }
393
394 #[test]
395 fn parse_cdxml_element_above_255_is_rejected() {
396 let cdxml = r#"<CDXML><fragment>
399<n id="1" Element="300" p="0 0"/>
400</fragment></CDXML>"#;
401 let result = parse_cdxml(cdxml);
402 assert!(
403 matches!(result, Err(CdxmlError::UnknownAtomicNumber(300))),
404 "Element=300 must return UnknownAtomicNumber(300)"
405 );
406 }
407
408 const TWO_FRAGMENT_CDXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
411<CDXML>
412<fragment>
413<n id="1" p="10.0 20.0" Element="6"/>
414<n id="2" p="25.0 20.0" Element="8"/>
415<b B="1" E="2" Order="1"/>
416</fragment>
417<fragment>
418<n id="3" p="60.0 20.0" Element="7"/>
419<n id="4" p="75.0 20.0" Element="6"/>
420<n id="5" p="90.0 20.0" Element="6"/>
421<b B="3" E="4" Order="1"/>
422<b B="4" E="5" Order="1"/>
423</fragment>
424</CDXML>"#;
425
426 #[test]
427 fn parse_cdxml_all_two_fragments_count() {
428 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
429 assert_eq!(mols.len(), 2, "two <fragment> elements → two molecules");
430 }
431
432 #[test]
433 fn parse_cdxml_all_first_fragment_co() {
434 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
435 let (mol, _) = &mols[0];
436 assert_eq!(mol.atom_count(), 2, "first fragment: C + O");
437 assert_eq!(mol.bond_count(), 1);
438 }
439
440 #[test]
441 fn parse_cdxml_all_second_fragment_ncc() {
442 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
443 let (mol, _) = &mols[1];
444 assert_eq!(mol.atom_count(), 3, "second fragment: N + C + C");
445 assert_eq!(mol.bond_count(), 2);
446 }
447
448 #[test]
449 fn parse_cdxml_all_coords_independent() {
450 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
451 let (_, coords0) = &mols[0];
452 let (_, coords1) = &mols[1];
453 assert!((coords0[0].0 - 10.0).abs() < 0.01);
455 assert!((coords1[0].0 - 60.0).abs() < 0.01);
457 }
458
459 #[test]
460 fn parse_cdxml_empty_doc_returns_empty_vec() {
461 let cdxml = r#"<?xml version="1.0"?><CDXML></CDXML>"#;
462 let mols = parse_cdxml_all(cdxml).unwrap();
463 assert!(mols.is_empty(), "empty CDXML → empty Vec");
464 }
465
466 #[test]
471 fn parse_cdxml_aromatic_bond_order_1_5() {
472 let cdxml = r#"<CDXML><fragment>
476<n id="1" Element="6" p="0 0"/>
477<n id="2" Element="6" p="10 0"/>
478<b B="1" E="2" Order="1.5"/>
479</fragment></CDXML>"#;
480 let (mol, _) = parse_cdxml(cdxml).unwrap();
481 let bond = mol.bond(chematic_core::BondIdx(0));
482 assert_eq!(bond.order, BondOrder::Aromatic, "Order=1.5 → Aromatic");
483 }
484
485 #[test]
486 fn parse_cdxml_benzene_all_aromatic_bonds() {
487 let cdxml = r#"<CDXML><fragment>
489<n id="1" Element="6" p="0 0"/>
490<n id="2" Element="6" p="10 0"/>
491<n id="3" Element="6" p="20 0"/>
492<n id="4" Element="6" p="30 0"/>
493<n id="5" Element="6" p="20 10"/>
494<n id="6" Element="6" p="10 10"/>
495<b B="1" E="2" Order="1.5"/>
496<b B="2" E="3" Order="1.5"/>
497<b B="3" E="4" Order="1.5"/>
498<b B="4" E="5" Order="1.5"/>
499<b B="5" E="6" Order="1.5"/>
500<b B="6" E="1" Order="1.5"/>
501</fragment></CDXML>"#;
502 let (mol, _) = parse_cdxml(cdxml).unwrap();
503 assert_eq!(mol.atom_count(), 6, "benzene has 6 atoms");
504 assert_eq!(mol.bond_count(), 6, "benzene has 6 bonds");
505 let all_aromatic = mol.bonds().all(|(_, b)| b.order == BondOrder::Aromatic);
506 assert!(all_aromatic, "all bonds must be Aromatic");
507 }
508}