Skip to main content

chematic_mol/
cdxml.rs

1//! ChemDraw XML (CDXML) parser — **read-only**.
2//!
3//! CDXML is a proprietary XML format produced by ChemDraw (PerkinElmer /
4//! Revvity).  This parser handles the minimal subset needed to extract
5//! molecular structure from a CDXML document.
6//!
7//! # Supported elements / attributes
8//!
9//! `<n>` (atom): `id`, `Element` (atomic number), `p` ("x y" 2D coords),
10//!               `NumHydrogens`, `Charge`, `Isotope`
11//!
12//! `<b>` (bond): `B` (begin atom id), `E` (end atom id), `Order` (1/2/3,
13//!               defaults to 1)
14//!
15//! # Limitations
16//!
17//! - Only the first `<fragment>` in the document is returned as a single
18//!   molecule.  Multi-molecule documents (multiple fragments) are not yet
19//!   supported.
20//! - Write support is **not** implemented (CDXML is a proprietary format;
21//!   writing files that ChemDraw will accept requires undocumented attributes).
22//! - Wedge/hash bond stereo (tetrahedral) is derived from `Display` attribute.
23//! - E/Z double-bond stereo is derived from 2D coordinates via `assign_ez_from_2d`.
24//! - Presentation-only nodes (text boxes, arrows, etc.) are silently skipped.
25
26use 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// ---------------------------------------------------------------------------
34// Error type
35// ---------------------------------------------------------------------------
36
37/// Error returned when parsing a CDXML document fails.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum CdxmlError {
40    /// An atom `Element` attribute contained an unknown atomic number.
41    UnknownAtomicNumber(u32),
42    /// A bond referenced an atom id that was not defined.
43    UnknownAtomRef(String),
44    /// A `<b>` bond element is missing a `B` or `E` attribute.
45    MissingBondEndpoint,
46    /// The `p` coordinate attribute could not be parsed.
47    InvalidCoords(String),
48    /// The document contains more atoms than the parser's safety limit.
49    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
66// ---------------------------------------------------------------------------
67// Parser
68// ---------------------------------------------------------------------------
69
70/// Parse a CDXML document and return the first molecular fragment.
71///
72/// Convenience wrapper around [`parse_cdxml_all`].
73/// Parse a ChemDraw CDXML string into a molecule and 2D coordinates.
74///
75/// **Coordinate system:** The returned `coords` use **ChemDraw Y-down convention**
76/// (Y increases downward, matching screen/SVG pixel space). No Y-axis conversion is required
77/// for SVG rendering; coordinates can be used directly in [`crate::svg::render_svg`] or similar.
78pub 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/// Parse all molecular fragments from a CDXML document.
87///
88/// Each `<fragment>` element in the document is parsed as a separate
89/// molecule.  Returns a `Vec` of `(Molecule, 2D-coords)` pairs in document
90/// order.  Coordinates are in CDXML point units (1/72 inch).
91///
92/// # Stereochemistry
93///
94/// Wedge bonds are derived from the `Display` attribute of `<b>` elements:
95/// `"WedgeBegin"` / `"WedgedHashBegin"` → [`BondOrder::Up`];
96/// `"Hash"` / `"Dash"` / `"WedgeEnd"` / `"WedgedHashEnd"` → [`BondOrder::Down`].
97/// Accumulator for a single CDXML `<fragment>` being parsed.
98#[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        // Derive E/Z stereo from 2D atom positions (RDKit issue #9356: CDXML loses E/Z).
157        assign_ez_from_2d(&mut mol, &coords);
158        results.push((mol, coords));
159        *self = FragAccum::default();
160        Ok(())
161    }
162}
163
164/// Maximum atoms allowed in a single CDXML fragment (DoS guard).
165pub 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                // "1.5" is used by some CDXML writers (e.g. OpenBabel) for
253                // aromatic bonds.  Store as Aromatic so that aromaticity
254                // perception is not required to recover the correct bond type.
255                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    // Handle documents without explicit </fragment> closing tags.
276    acc.flush(&mut results)?;
277
278    Ok(results)
279}
280
281/// True if `line` starts a CDXML `<n` atom node tag.
282fn is_n_tag(line: &str) -> bool {
283    (line.starts_with("<n ") || line.starts_with("<n\t") || line == "<n>")
284        && !line.starts_with("<node") // avoid accidental match on <node>
285}
286
287/// True if `line` starts a CDXML `<b` bond tag.
288fn 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// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    // Minimal hand-crafted CDXML for ethanol (C-C-O) with 2 bonds.
305    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        // Atom 1 (first C): p="10.0 20.0"
337        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        // Element=300 would silently truncate to 44 (Ru) via u32 as u8.
397        // Must be caught as UnknownAtomicNumber before the cast.
398        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    // B4: multi-fragment CDXML document tests
409
410    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        // First fragment atom 0 at x=10
454        assert!((coords0[0].0 - 10.0).abs() < 0.01);
455        // Second fragment atom 0 at x=60
456        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    // -----------------------------------------------------------------------
467    // Order="1.5" aromatic bond (OpenBabel / some CDXML writers)
468    // -----------------------------------------------------------------------
469
470    #[test]
471    fn parse_cdxml_aromatic_bond_order_1_5() {
472        // Some CDXML producers (e.g. tools derived from OpenBabel) write
473        // aromatic bonds as Order="1.5".  These must be stored as
474        // BondOrder::Aromatic rather than falling through to Single.
475        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        // Benzene written with all Order="1.5" bonds.
488        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}