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//! - Stereochemistry attributes are ignored.
23//! - Presentation-only nodes (text boxes, arrows, etc.) are silently skipped.
24
25use std::collections::HashMap;
26
27use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
28
29use crate::cml::parse_xml_attrs;
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Error returned when parsing a CDXML document fails.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CdxmlError {
38    /// An atom `Element` attribute contained an unknown atomic number.
39    UnknownAtomicNumber(u32),
40    /// A bond referenced an atom id that was not defined.
41    UnknownAtomRef(String),
42    /// A `<b>` bond element is missing a `B` or `E` attribute.
43    MissingBondEndpoint,
44    /// The `p` coordinate attribute could not be parsed.
45    InvalidCoords(String),
46}
47
48impl std::fmt::Display for CdxmlError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            CdxmlError::UnknownAtomicNumber(n) => write!(f, "unknown atomic number: {n}"),
52            CdxmlError::UnknownAtomRef(s)      => write!(f, "unknown atom ref: {s}"),
53            CdxmlError::MissingBondEndpoint    => write!(f, "bond missing B or E attribute"),
54            CdxmlError::InvalidCoords(s)       => write!(f, "invalid p coords: {s}"),
55        }
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Parser
61// ---------------------------------------------------------------------------
62
63/// Parse a CDXML document and return the first molecular fragment.
64///
65/// Convenience wrapper around [`parse_cdxml_all`].
66pub fn parse_cdxml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CdxmlError> {
67    let mut all = parse_cdxml_all(input)?;
68    if all.is_empty() {
69        return Ok((MoleculeBuilder::new().build(), vec![]));
70    }
71    Ok(all.remove(0))
72}
73
74/// Parse all molecular fragments from a CDXML document.
75///
76/// Each `<fragment>` element in the document is parsed as a separate
77/// molecule.  Returns a `Vec` of `(Molecule, 2D-coords)` pairs in document
78/// order.  Coordinates are in CDXML point units (1/72 inch).
79///
80/// # Stereochemistry
81///
82/// Wedge bonds are derived from the `Display` attribute of `<b>` elements:
83/// `"WedgeBegin"` / `"WedgedHashBegin"` → [`BondOrder::Up`];
84/// `"Hash"` / `"Dash"` / `"WedgeEnd"` / `"WedgedHashEnd"` → [`BondOrder::Down`].
85pub fn parse_cdxml_all(input: &str) -> Result<Vec<(Molecule, Vec<(f64, f64)>)>, CdxmlError> {
86    // Per-fragment atom and bond accumulators.
87    let mut atom_ids:   Vec<String>    = Vec::new();
88    let mut atom_elems: Vec<Element>   = Vec::new();
89    let mut atom_charges: Vec<i8>      = Vec::new();
90    let mut atom_isotopes: Vec<Option<u16>> = Vec::new();
91    let mut atom_h:     Vec<Option<u8>> = Vec::new();
92    let mut atom_xs:    Vec<f64>       = Vec::new();
93    let mut atom_ys:    Vec<f64>       = Vec::new();
94
95    let mut bond_bs:    Vec<String>    = Vec::new();
96    let mut bond_es:    Vec<String>    = Vec::new();
97    let mut bond_ords:  Vec<BondOrder> = Vec::new();
98
99    let mut results: Vec<(Molecule, Vec<(f64, f64)>)> = Vec::new();
100    let mut in_fragment = false;
101
102    let flush = |atom_ids: &mut Vec<String>, atom_elems: &mut Vec<Element>,
103                 atom_charges: &mut Vec<i8>, atom_isotopes: &mut Vec<Option<u16>>,
104                 atom_h: &mut Vec<Option<u8>>, atom_xs: &mut Vec<f64>, atom_ys: &mut Vec<f64>,
105                 bond_bs: &mut Vec<String>, bond_es: &mut Vec<String>, bond_ords: &mut Vec<BondOrder>,
106                 results: &mut Vec<(Molecule, Vec<(f64, f64)>)>|
107        -> Result<(), CdxmlError>
108    {
109        if atom_ids.is_empty() && bond_bs.is_empty() { return Ok(()); }
110
111        let mut id_to_pos: HashMap<&str, usize> = HashMap::new();
112        for (i, id) in atom_ids.iter().enumerate() { id_to_pos.insert(id.as_str(), i); }
113
114        let mut builder = MoleculeBuilder::new();
115        let mut idx_map: HashMap<usize, AtomIdx> = HashMap::new();
116        let mut coords: Vec<(f64, f64)> = Vec::new();
117
118        for (i, _) in atom_ids.iter().enumerate() {
119            let mut a = Atom::new(atom_elems[i]);
120            a.charge = atom_charges[i];
121            a.isotope = atom_isotopes[i];
122            a.hydrogen_count = atom_h[i];
123            let new_idx = builder.add_atom(a);
124            idx_map.insert(i, new_idx);
125            coords.push((atom_xs[i], atom_ys[i]));
126        }
127
128        for k in 0..bond_bs.len() {
129            let pos_b = *id_to_pos.get(bond_bs[k].as_str())
130                .ok_or_else(|| CdxmlError::UnknownAtomRef(bond_bs[k].clone()))?;
131            let pos_e = *id_to_pos.get(bond_es[k].as_str())
132                .ok_or_else(|| CdxmlError::UnknownAtomRef(bond_es[k].clone()))?;
133            let a1 = idx_map[&pos_b];
134            let a2 = idx_map[&pos_e];
135            builder.add_bond(a1, a2, bond_ords[k])
136                .map_err(|_| CdxmlError::UnknownAtomRef(format!("{} {}", bond_bs[k], bond_es[k])))?;
137        }
138
139        results.push((builder.build(), coords));
140        atom_ids.clear(); atom_elems.clear(); atom_charges.clear(); atom_isotopes.clear();
141        atom_h.clear(); atom_xs.clear(); atom_ys.clear();
142        bond_bs.clear(); bond_es.clear(); bond_ords.clear();
143        Ok(())
144    };
145
146    for raw_line in input.lines() {
147        let line = raw_line.trim();
148        if line.is_empty() { continue; }
149
150        if line.starts_with("<fragment") {
151            in_fragment = true;
152            atom_ids.clear(); atom_elems.clear(); atom_charges.clear(); atom_isotopes.clear();
153            atom_h.clear(); atom_xs.clear(); atom_ys.clear();
154            bond_bs.clear(); bond_es.clear(); bond_ords.clear();
155            continue;
156        }
157
158        if line.starts_with("</fragment>") {
159            flush(&mut atom_ids, &mut atom_elems, &mut atom_charges, &mut atom_isotopes,
160                  &mut atom_h, &mut atom_xs, &mut atom_ys,
161                  &mut bond_bs, &mut bond_es, &mut bond_ords, &mut results)?;
162            in_fragment = false;
163            continue;
164        }
165
166        if is_n_tag(line) {
167            let attrs = parse_xml_attrs(line);
168            let id = match attrs.get("id") { Some(s) => s.clone(), None => continue };
169            let element_num: u32 = attrs.get("Element")
170                .and_then(|s| s.trim().parse().ok()).unwrap_or(6);
171            let element = Element::from_atomic_number(element_num as u8)
172                .ok_or(CdxmlError::UnknownAtomicNumber(element_num))?;
173            let charge = attrs.get("Charge").and_then(|s| s.trim().parse().ok()).unwrap_or(0);
174            let isotope = attrs.get("Isotope")
175                .and_then(|s| s.trim().parse::<u16>().ok()).filter(|&v| v > 0);
176            let hcount = attrs.get("NumHydrogens").and_then(|s| s.trim().parse().ok());
177            let (x, y) = if let Some(p) = attrs.get("p") {
178                let parts: Vec<&str> = p.split_whitespace().collect();
179                if parts.len() < 2 { return Err(CdxmlError::InvalidCoords(p.clone())); }
180                (parts[0].parse().unwrap_or(0.0), parts[1].parse().unwrap_or(0.0))
181            } else { (0.0, 0.0) };
182            atom_ids.push(id); atom_elems.push(element); atom_charges.push(charge);
183            atom_isotopes.push(isotope); atom_h.push(hcount); atom_xs.push(x); atom_ys.push(y);
184            continue;
185        }
186
187        if is_b_tag(line) {
188            let attrs = parse_xml_attrs(line);
189            let b = attrs.get("B").cloned().ok_or(CdxmlError::MissingBondEndpoint)?;
190            let e = attrs.get("E").cloned().ok_or(CdxmlError::MissingBondEndpoint)?;
191            let base: BondOrder = match attrs.get("Order").map(String::as_str) {
192                Some("2") => BondOrder::Double,
193                Some("3") => BondOrder::Triple,
194                _         => BondOrder::Single,
195            };
196            let order = if base == BondOrder::Single {
197                match attrs.get("Display").map(String::as_str) {
198                    Some("WedgeBegin") | Some("WedgedHashBegin") => BondOrder::Up,
199                    Some("Hash") | Some("Dash") | Some("WedgeEnd") | Some("WedgedHashEnd")
200                        => BondOrder::Down,
201                    _ => BondOrder::Single,
202                }
203            } else { base };
204            bond_bs.push(b); bond_es.push(e); bond_ords.push(order);
205        }
206    }
207
208    // Handle documents without explicit </fragment> closing tags.
209    if !atom_ids.is_empty() || !bond_bs.is_empty() {
210        flush(&mut atom_ids, &mut atom_elems, &mut atom_charges, &mut atom_isotopes,
211              &mut atom_h, &mut atom_xs, &mut atom_ys,
212              &mut bond_bs, &mut bond_es, &mut bond_ords, &mut results)?;
213    }
214
215    Ok(results)
216}
217
218/// True if `line` starts a CDXML `<n` atom node tag.
219fn is_n_tag(line: &str) -> bool {
220    (line.starts_with("<n ") || line.starts_with("<n\t") || line == "<n>")
221        && !line.starts_with("<node") // avoid accidental match on <node>
222}
223
224/// True if `line` starts a CDXML `<b` bond tag.
225fn is_b_tag(line: &str) -> bool {
226    line.starts_with("<b ") || line.starts_with("<b\t") || line == "<b>"
227        || line.starts_with("<b/>") || line.starts_with("<b>")
228}
229
230// ---------------------------------------------------------------------------
231// Tests
232// ---------------------------------------------------------------------------
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    // Minimal hand-crafted CDXML for ethanol (C-C-O) with 2 bonds.
239    const ETHANOL_CDXML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
240<!DOCTYPE CDXML SYSTEM "http://www.cambridgesoft.com/xml/cdxml.dtd">
241<CDXML>
242<fragment>
243<n id="1" p="10.0 20.0" Element="6" NumHydrogens="3"/>
244<n id="2" p="25.0 20.0" Element="6" NumHydrogens="2"/>
245<n id="3" p="40.0 20.0" Element="8" NumHydrogens="1"/>
246<b B="1" E="2" Order="1"/>
247<b B="2" E="3" Order="1"/>
248</fragment>
249</CDXML>"#;
250
251    #[test]
252    fn parse_cdxml_ethanol_atom_count() {
253        let (mol, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
254        assert_eq!(mol.atom_count(), 3, "ethanol: 3 heavy atoms");
255        assert_eq!(mol.bond_count(), 2, "ethanol: 2 bonds");
256        assert_eq!(coords.len(), 3, "one coord per atom");
257    }
258
259    #[test]
260    fn parse_cdxml_ethanol_elements() {
261        let (mol, _) = parse_cdxml(ETHANOL_CDXML).unwrap();
262        let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
263        assert!(elems.contains(&"C"), "should contain C");
264        assert!(elems.contains(&"O"), "should contain O");
265    }
266
267    #[test]
268    fn parse_cdxml_ethanol_coords() {
269        let (_, coords) = parse_cdxml(ETHANOL_CDXML).unwrap();
270        // Atom 1 (first C): p="10.0 20.0"
271        assert!((coords[0].0 - 10.0).abs() < 0.01, "first atom x=10.0: {:?}", coords[0]);
272        assert!((coords[0].1 - 20.0).abs() < 0.01, "first atom y=20.0: {:?}", coords[0]);
273    }
274
275    #[test]
276    fn parse_cdxml_carbon_element_6() {
277        let cdxml = r#"<CDXML><fragment>
278<n id="1" Element="6" p="0 0"/>
279</fragment></CDXML>"#;
280        let (mol, _) = parse_cdxml(cdxml).unwrap();
281        assert_eq!(mol.atom_count(), 1);
282        let atom = mol.atom(chematic_core::AtomIdx(0));
283        assert_eq!(atom.element.symbol(), "C", "Element=6 → Carbon");
284    }
285
286    #[test]
287    fn parse_cdxml_double_bond() {
288        let cdxml = r#"<CDXML><fragment>
289<n id="1" Element="6" p="0 0"/>
290<n id="2" Element="8" p="10 0"/>
291<b B="1" E="2" Order="2"/>
292</fragment></CDXML>"#;
293        let (mol, _) = parse_cdxml(cdxml).unwrap();
294        let bond = mol.bond(chematic_core::BondIdx(0));
295        assert_eq!(bond.order, BondOrder::Double, "Order=2 → Double");
296    }
297
298    #[test]
299    fn parse_cdxml_charge() {
300        let cdxml = r#"<CDXML><fragment>
301<n id="1" Element="7" Charge="1" p="0 0"/>
302</fragment></CDXML>"#;
303        let (mol, _) = parse_cdxml(cdxml).unwrap();
304        let atom = mol.atom(chematic_core::AtomIdx(0));
305        assert_eq!(atom.charge, 1, "Charge=1 → N+");
306    }
307
308    #[test]
309    fn parse_cdxml_unknown_atomic_number_returns_err() {
310        let cdxml = r#"<CDXML><fragment>
311<n id="1" Element="999" p="0 0"/>
312</fragment></CDXML>"#;
313        let result = parse_cdxml(cdxml);
314        assert!(
315            matches!(result, Err(CdxmlError::UnknownAtomicNumber(_))),
316            "unknown atomic number should return Err"
317        );
318    }
319}