1use std::collections::HashMap;
29
30use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
31use chematic_perception::assign_ez_from_2d;
32
33use crate::cml::parse_xml_attrs;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum CdxmlError {
42 UnknownAtomicNumber(u32),
44 UnknownAtomRef(String),
46 MissingBondEndpoint,
48 InvalidCoords(String),
50 TooManyAtoms(usize),
52}
53
54impl std::fmt::Display for CdxmlError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 CdxmlError::UnknownAtomicNumber(n) => write!(f, "unknown atomic number: {n}"),
58 CdxmlError::UnknownAtomRef(s) => write!(f, "unknown atom ref: {s}"),
59 CdxmlError::MissingBondEndpoint => write!(f, "bond missing B or E attribute"),
60 CdxmlError::InvalidCoords(s) => write!(f, "invalid p coords: {s}"),
61 CdxmlError::TooManyAtoms(n) => write!(f, "CDXML document exceeds atom limit ({n})"),
62 }
63 }
64}
65
66impl std::error::Error for CdxmlError {}
67
68pub fn parse_cdxml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CdxmlError> {
81 let mut all = parse_cdxml_all(input)?;
82 if all.is_empty() {
83 return Ok((MoleculeBuilder::new().build(), vec![]));
84 }
85 Ok(all.remove(0))
86}
87
88#[derive(Default)]
101struct FragAccum {
102 atom_ids: Vec<String>,
103 atom_elems: Vec<Element>,
104 atom_charges: Vec<i8>,
105 atom_isotopes: Vec<Option<u16>>,
106 atom_h: Vec<Option<u8>>,
107 atom_xs: Vec<f64>,
108 atom_ys: Vec<f64>,
109 bond_bs: Vec<String>,
110 bond_es: Vec<String>,
111 bond_ords: Vec<BondOrder>,
112}
113
114impl FragAccum {
115 fn is_empty(&self) -> bool {
116 self.atom_ids.is_empty() && self.bond_bs.is_empty()
117 }
118
119 fn flush(&mut self, results: &mut Vec<(Molecule, Vec<(f64, f64)>)>) -> Result<(), CdxmlError> {
120 if self.is_empty() {
121 return Ok(());
122 }
123
124 let mut id_to_pos: HashMap<&str, usize> = HashMap::new();
125 for (i, id) in self.atom_ids.iter().enumerate() {
126 id_to_pos.insert(id.as_str(), i);
127 }
128
129 let mut builder = MoleculeBuilder::new();
130 let mut idx_map: HashMap<usize, AtomIdx> = HashMap::new();
131 let mut coords: Vec<(f64, f64)> = Vec::new();
132
133 for i in 0..self.atom_ids.len() {
134 let mut a = Atom::new(self.atom_elems[i]);
135 a.charge = self.atom_charges[i];
136 a.isotope = self.atom_isotopes[i];
137 a.hydrogen_count = self.atom_h[i];
138 let new_idx = builder.add_atom(a);
139 idx_map.insert(i, new_idx);
140 coords.push((self.atom_xs[i], self.atom_ys[i]));
141 }
142
143 for k in 0..self.bond_bs.len() {
144 let pos_b = *id_to_pos
145 .get(self.bond_bs[k].as_str())
146 .ok_or_else(|| CdxmlError::UnknownAtomRef(self.bond_bs[k].clone()))?;
147 let pos_e = *id_to_pos
148 .get(self.bond_es[k].as_str())
149 .ok_or_else(|| CdxmlError::UnknownAtomRef(self.bond_es[k].clone()))?;
150 let a1 = idx_map[&pos_b];
151 let a2 = idx_map[&pos_e];
152 builder.add_bond(a1, a2, self.bond_ords[k]).map_err(|_| {
153 CdxmlError::UnknownAtomRef(format!("{} {}", self.bond_bs[k], self.bond_es[k]))
154 })?;
155 }
156
157 let mut mol = builder.build();
158 assign_ez_from_2d(&mut mol, &coords);
160 results.push((mol, coords));
161 *self = FragAccum::default();
162 Ok(())
163 }
164}
165
166pub const CDXML_MAX_ATOMS: usize = 10_000;
168
169#[allow(clippy::type_complexity)]
170pub fn parse_cdxml_all(input: &str) -> Result<Vec<(Molecule, Vec<(f64, f64)>)>, CdxmlError> {
171 let mut acc = FragAccum::default();
172 let mut results: Vec<(Molecule, Vec<(f64, f64)>)> = Vec::new();
173
174 for raw_line in input.lines() {
175 let line = raw_line.trim();
176 if line.is_empty() {
177 continue;
178 }
179
180 if line.starts_with("<fragment") {
181 acc = FragAccum::default();
182 continue;
183 }
184
185 if line.starts_with("</fragment>") {
186 acc.flush(&mut results)?;
187 continue;
188 }
189
190 if is_n_tag(line) {
191 let attrs = parse_xml_attrs(line);
192 let id = match attrs.get("id") {
193 Some(s) => s.clone(),
194 None => continue,
195 };
196 let element_num: u32 = attrs
197 .get("Element")
198 .and_then(|s| s.trim().parse().ok())
199 .unwrap_or(6);
200 if element_num > 255 {
201 return Err(CdxmlError::UnknownAtomicNumber(element_num));
202 }
203 let element = Element::from_atomic_number(element_num as u8)
204 .ok_or(CdxmlError::UnknownAtomicNumber(element_num))?;
205 let charge = attrs
206 .get("Charge")
207 .and_then(|s| s.trim().parse().ok())
208 .unwrap_or(0);
209 let isotope = attrs
210 .get("Isotope")
211 .and_then(|s| s.trim().parse::<u16>().ok())
212 .filter(|&v| v > 0);
213 let hcount = attrs
214 .get("NumHydrogens")
215 .and_then(|s| s.trim().parse().ok());
216 let (x, y) = if let Some(p) = attrs.get("p") {
217 let parts: Vec<&str> = p.split_whitespace().collect();
218 if parts.len() < 2 {
219 return Err(CdxmlError::InvalidCoords(p.clone()));
220 }
221 (
222 parts[0].parse().unwrap_or(0.0),
223 parts[1].parse().unwrap_or(0.0),
224 )
225 } else {
226 (0.0, 0.0)
227 };
228 if acc.atom_ids.len() >= CDXML_MAX_ATOMS {
229 return Err(CdxmlError::TooManyAtoms(CDXML_MAX_ATOMS));
230 }
231 acc.atom_ids.push(id);
232 acc.atom_elems.push(element);
233 acc.atom_charges.push(charge);
234 acc.atom_isotopes.push(isotope);
235 acc.atom_h.push(hcount);
236 acc.atom_xs.push(x);
237 acc.atom_ys.push(y);
238 continue;
239 }
240
241 if is_b_tag(line) {
242 let attrs = parse_xml_attrs(line);
243 let b = attrs
244 .get("B")
245 .cloned()
246 .ok_or(CdxmlError::MissingBondEndpoint)?;
247 let e = attrs
248 .get("E")
249 .cloned()
250 .ok_or(CdxmlError::MissingBondEndpoint)?;
251 let base: BondOrder = match attrs.get("Order").map(String::as_str) {
252 Some("2") => BondOrder::Double,
253 Some("3") => BondOrder::Triple,
254 Some("1.5") => BondOrder::Aromatic,
258 _ => BondOrder::Single,
259 };
260 let order = if base == BondOrder::Single {
261 match attrs.get("Display").map(String::as_str) {
262 Some("WedgeBegin") | Some("WedgedHashBegin") => BondOrder::Up,
263 Some("Hash") | Some("Dash") | Some("WedgeEnd") | Some("WedgedHashEnd") => {
264 BondOrder::Down
265 }
266 _ => BondOrder::Single,
267 }
268 } else {
269 base
270 };
271 acc.bond_bs.push(b);
272 acc.bond_es.push(e);
273 acc.bond_ords.push(order);
274 }
275 }
276
277 acc.flush(&mut results)?;
279
280 Ok(results)
281}
282
283fn is_n_tag(line: &str) -> bool {
285 (line.starts_with("<n ") || line.starts_with("<n\t") || line == "<n>")
286 && !line.starts_with("<node") }
288
289fn is_b_tag(line: &str) -> bool {
291 line.starts_with("<b ")
292 || line.starts_with("<b\t")
293 || line == "<b>"
294 || line.starts_with("<b/>")
295 || line.starts_with("<b>")
296}
297
298pub fn write_cdxml(mol: &Molecule, coords: &[(f64, f64)]) -> String {
309 let mut out =
310 String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<CDXML>\n<page>\n<fragment>\n");
311
312 for (i, (idx, atom)) in mol.atoms().enumerate() {
313 let id = idx.0 + 1;
314 let (x, y) = coords.get(i).copied().unwrap_or((0.0, 0.0));
315 let mut parts = vec![
316 format!("id=\"{id}\""),
317 format!("p=\"{x} {y}\""),
318 format!("Element=\"{}\"", atom.element.atomic_number()),
319 ];
320 if let Some(h) = atom.hydrogen_count {
321 parts.push(format!("NumHydrogens=\"{h}\""));
322 }
323 if atom.charge != 0 {
324 parts.push(format!("Charge=\"{}\"", atom.charge));
325 }
326 if let Some(iso) = atom.isotope {
327 parts.push(format!("Isotope=\"{iso}\""));
328 }
329 out.push_str(&format!("<n {}/>\n", parts.join(" ")));
330 }
331
332 for (_, bond) in mol.bonds() {
333 let b = bond.atom1.0 + 1;
334 let e = bond.atom2.0 + 1;
335 let (order_attr, display_attr) = match bond.order {
339 BondOrder::Double => ("2", None),
340 BondOrder::Triple => ("3", None),
341 BondOrder::Aromatic => ("1.5", None),
342 BondOrder::Up => ("1", Some("WedgeBegin")),
343 BondOrder::Down => ("1", Some("Hash")),
344 _ => ("1", None),
345 };
346 match display_attr {
347 Some(d) => out.push_str(&format!(
348 "<b B=\"{b}\" E=\"{e}\" Order=\"{order_attr}\" Display=\"{d}\"/>\n"
349 )),
350 None => out.push_str(&format!(
351 "<b B=\"{b}\" E=\"{e}\" Order=\"{order_attr}\"/>\n"
352 )),
353 }
354 }
355
356 out.push_str("</fragment>\n</page>\n</CDXML>\n");
357 out
358}
359
360#[cfg(test)]
365mod tests {
366 use super::*;
367
368 const ETHANOL_CDXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
370<!DOCTYPE CDXML SYSTEM "http://www.cambridgesoft.com/xml/cdxml.dtd">
371<CDXML>
372<fragment>
373<n id="1" p="10.0 20.0" Element="6" NumHydrogens="3"/>
374<n id="2" p="25.0 20.0" Element="6" NumHydrogens="2"/>
375<n id="3" p="40.0 20.0" Element="8" NumHydrogens="1"/>
376<b B="1" E="2" Order="1"/>
377<b B="2" E="3" Order="1"/>
378</fragment>
379</CDXML>"#;
380
381 #[test]
382 fn parse_cdxml_ethanol_atom_count() {
383 let (mol, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
384 assert_eq!(mol.atom_count(), 3, "ethanol: 3 heavy atoms");
385 assert_eq!(mol.bond_count(), 2, "ethanol: 2 bonds");
386 assert_eq!(coords.len(), 3, "one coord per atom");
387 }
388
389 #[test]
390 fn parse_cdxml_ethanol_elements() {
391 let (mol, _) = parse_cdxml(ETHANOL_CDXML).unwrap();
392 let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
393 assert!(elems.contains(&"C"), "should contain C");
394 assert!(elems.contains(&"O"), "should contain O");
395 }
396
397 #[test]
398 fn parse_cdxml_ethanol_coords() {
399 let (_, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
400 assert!(
402 (coords[0].0 - 10.0).abs() < 0.01,
403 "first atom x=10.0: {:?}",
404 coords[0]
405 );
406 assert!(
407 (coords[0].1 - 20.0).abs() < 0.01,
408 "first atom y=20.0: {:?}",
409 coords[0]
410 );
411 }
412
413 #[test]
414 fn parse_cdxml_carbon_element_6() {
415 let cdxml = r#"<CDXML><fragment>
416<n id="1" Element="6" p="0 0"/>
417</fragment></CDXML>"#;
418 let (mol, _) = parse_cdxml(cdxml).unwrap();
419 assert_eq!(mol.atom_count(), 1);
420 let atom = mol.atom(chematic_core::AtomIdx(0));
421 assert_eq!(atom.element.symbol(), "C", "Element=6 → Carbon");
422 }
423
424 #[test]
425 fn parse_cdxml_double_bond() {
426 let cdxml = r#"<CDXML><fragment>
427<n id="1" Element="6" p="0 0"/>
428<n id="2" Element="8" p="10 0"/>
429<b B="1" E="2" Order="2"/>
430</fragment></CDXML>"#;
431 let (mol, _) = parse_cdxml(cdxml).unwrap();
432 let bond = mol.bond(chematic_core::BondIdx(0));
433 assert_eq!(bond.order, BondOrder::Double, "Order=2 → Double");
434 }
435
436 #[test]
437 fn parse_cdxml_charge() {
438 let cdxml = r#"<CDXML><fragment>
439<n id="1" Element="7" Charge="1" p="0 0"/>
440</fragment></CDXML>"#;
441 let (mol, _) = parse_cdxml(cdxml).unwrap();
442 let atom = mol.atom(chematic_core::AtomIdx(0));
443 assert_eq!(atom.charge, 1, "Charge=1 → N+");
444 }
445
446 #[test]
447 fn parse_cdxml_unknown_atomic_number_returns_err() {
448 let cdxml = r#"<CDXML><fragment>
449<n id="1" Element="999" p="0 0"/>
450</fragment></CDXML>"#;
451 let result = parse_cdxml(cdxml);
452 assert!(
453 matches!(result, Err(CdxmlError::UnknownAtomicNumber(_))),
454 "unknown atomic number should return Err"
455 );
456 }
457
458 #[test]
459 fn parse_cdxml_element_above_255_is_rejected() {
460 let cdxml = r#"<CDXML><fragment>
463<n id="1" Element="300" p="0 0"/>
464</fragment></CDXML>"#;
465 let result = parse_cdxml(cdxml);
466 assert!(
467 matches!(result, Err(CdxmlError::UnknownAtomicNumber(300))),
468 "Element=300 must return UnknownAtomicNumber(300)"
469 );
470 }
471
472 const TWO_FRAGMENT_CDXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
475<CDXML>
476<fragment>
477<n id="1" p="10.0 20.0" Element="6"/>
478<n id="2" p="25.0 20.0" Element="8"/>
479<b B="1" E="2" Order="1"/>
480</fragment>
481<fragment>
482<n id="3" p="60.0 20.0" Element="7"/>
483<n id="4" p="75.0 20.0" Element="6"/>
484<n id="5" p="90.0 20.0" Element="6"/>
485<b B="3" E="4" Order="1"/>
486<b B="4" E="5" Order="1"/>
487</fragment>
488</CDXML>"#;
489
490 #[test]
491 fn parse_cdxml_all_two_fragments_count() {
492 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
493 assert_eq!(mols.len(), 2, "two <fragment> elements → two molecules");
494 }
495
496 #[test]
497 fn parse_cdxml_all_first_fragment_co() {
498 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
499 let (mol, _) = &mols[0];
500 assert_eq!(mol.atom_count(), 2, "first fragment: C + O");
501 assert_eq!(mol.bond_count(), 1);
502 }
503
504 #[test]
505 fn parse_cdxml_all_second_fragment_ncc() {
506 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
507 let (mol, _) = &mols[1];
508 assert_eq!(mol.atom_count(), 3, "second fragment: N + C + C");
509 assert_eq!(mol.bond_count(), 2);
510 }
511
512 #[test]
513 fn parse_cdxml_all_coords_independent() {
514 let mols = parse_cdxml_all(TWO_FRAGMENT_CDXML).unwrap();
515 let (_, coords0) = &mols[0];
516 let (_, coords1) = &mols[1];
517 assert!((coords0[0].0 - 10.0).abs() < 0.01);
519 assert!((coords1[0].0 - 60.0).abs() < 0.01);
521 }
522
523 #[test]
524 fn parse_cdxml_empty_doc_returns_empty_vec() {
525 let cdxml = r#"<?xml version="1.0"?><CDXML></CDXML>"#;
526 let mols = parse_cdxml_all(cdxml).unwrap();
527 assert!(mols.is_empty(), "empty CDXML → empty Vec");
528 }
529
530 #[test]
535 fn parse_cdxml_aromatic_bond_order_1_5() {
536 let cdxml = r#"<CDXML><fragment>
540<n id="1" Element="6" p="0 0"/>
541<n id="2" Element="6" p="10 0"/>
542<b B="1" E="2" Order="1.5"/>
543</fragment></CDXML>"#;
544 let (mol, _) = parse_cdxml(cdxml).unwrap();
545 let bond = mol.bond(chematic_core::BondIdx(0));
546 assert_eq!(bond.order, BondOrder::Aromatic, "Order=1.5 → Aromatic");
547 }
548
549 #[test]
550 fn parse_cdxml_benzene_all_aromatic_bonds() {
551 let cdxml = r#"<CDXML><fragment>
553<n id="1" Element="6" p="0 0"/>
554<n id="2" Element="6" p="10 0"/>
555<n id="3" Element="6" p="20 0"/>
556<n id="4" Element="6" p="30 0"/>
557<n id="5" Element="6" p="20 10"/>
558<n id="6" Element="6" p="10 10"/>
559<b B="1" E="2" Order="1.5"/>
560<b B="2" E="3" Order="1.5"/>
561<b B="3" E="4" Order="1.5"/>
562<b B="4" E="5" Order="1.5"/>
563<b B="5" E="6" Order="1.5"/>
564<b B="6" E="1" Order="1.5"/>
565</fragment></CDXML>"#;
566 let (mol, _) = parse_cdxml(cdxml).unwrap();
567 assert_eq!(mol.atom_count(), 6, "benzene has 6 atoms");
568 assert_eq!(mol.bond_count(), 6, "benzene has 6 bonds");
569 let all_aromatic = mol.bonds().all(|(_, b)| b.order == BondOrder::Aromatic);
570 assert!(all_aromatic, "all bonds must be Aromatic");
571 }
572
573 #[test]
578 fn write_cdxml_roundtrip_ethanol() {
579 let (mol, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
580 let written = write_cdxml(&mol, &coords);
581 let (mol2, coords2) = parse_cdxml(&written).unwrap();
582
583 assert_eq!(mol2.atom_count(), mol.atom_count());
584 assert_eq!(mol2.bond_count(), mol.bond_count());
585 let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
586 let elems2: Vec<&str> = mol2.atoms().map(|(_, a)| a.element.symbol()).collect();
587 assert_eq!(elems, elems2);
588 for (c1, c2) in coords.iter().zip(coords2.iter()) {
589 assert!((c1.0 - c2.0).abs() < 0.01);
590 assert!((c1.1 - c2.1).abs() < 0.01);
591 }
592 }
593
594 #[test]
595 fn write_cdxml_roundtrip_double_bond() {
596 let cdxml = r#"<CDXML><fragment>
597<n id="1" Element="6" p="0 0"/>
598<n id="2" Element="8" p="10 0"/>
599<b B="1" E="2" Order="2"/>
600</fragment></CDXML>"#;
601 let (mol, coords) = parse_cdxml(cdxml).unwrap();
602 let written = write_cdxml(&mol, &coords);
603 let (mol2, _) = parse_cdxml(&written).unwrap();
604 let bond2 = mol2.bond(chematic_core::BondIdx(0));
605 assert_eq!(bond2.order, BondOrder::Double);
606 }
607
608 #[test]
609 fn write_cdxml_roundtrip_wedge_bond() {
610 let cdxml = r#"<CDXML><fragment>
611<n id="1" Element="6" p="0 0"/>
612<n id="2" Element="6" p="10 0"/>
613<b B="1" E="2" Order="1" Display="WedgeBegin"/>
614</fragment></CDXML>"#;
615 let (mol, coords) = parse_cdxml(cdxml).unwrap();
616 let written = write_cdxml(&mol, &coords);
617 assert!(written.contains("Display=\"WedgeBegin\""));
618 let (mol2, _) = parse_cdxml(&written).unwrap();
619 let bond2 = mol2.bond(chematic_core::BondIdx(0));
620 assert_eq!(bond2.order, BondOrder::Up);
621 }
622
623 #[test]
624 fn write_cdxml_roundtrip_charge_isotope_hcount() {
625 let cdxml = r#"<CDXML><fragment>
626<n id="1" Element="7" Charge="1" Isotope="15" NumHydrogens="2" p="0 0"/>
627</fragment></CDXML>"#;
628 let (mol, coords) = parse_cdxml(cdxml).unwrap();
629 let written = write_cdxml(&mol, &coords);
630 let (mol2, _) = parse_cdxml(&written).unwrap();
631 let atom2 = mol2.atom(chematic_core::AtomIdx(0));
632 assert_eq!(atom2.charge, 1);
633 assert_eq!(atom2.isotope, Some(15));
634 assert_eq!(atom2.hydrogen_count, Some(2));
635 }
636}