Skip to main content

chematic_mol/
cml.rs

1//! CML (Chemical Markup Language) parser and writer.
2//!
3//! CML is an open XML-based format for chemical structures.  This module
4//! implements a minimal subset: `<molecule>`, `<atomArray>`, `<bondArray>`,
5//! `<atom>`, and `<bond>` elements with the attributes needed to represent
6//! 2D chemical structures.
7//!
8//! No external XML library is used; attributes are extracted with a simple
9//! key="value" scanner.
10//!
11//! # Supported attributes
12//!
13//! `<atom>`: `id`, `elementType`, `x2`, `y2`, `formalCharge`, `isotope`,
14//!           `hydrogenCount`, `isotopeNumber`
15//!
16//! `<bond>`: `atomRefs2` (two atom ids separated by a space), `order`
17//!           ("1" / "2" / "3" / "A" / "S" / "D" / "T")
18//!
19//! # Limitations
20//!
21//! - 3D coordinates (x3/y3/z3) are parsed but not returned (only 2D x2/y2).
22//! - Aromatic bonds ("A") are stored as alternating single/double bonds (no
23//!   special aromatic bond order in the core model).
24//! - Stereochemistry attributes are ignored.
25//! - The CML namespace declaration is accepted but not validated.
26
27use std::collections::HashMap;
28
29use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Error returned when parsing a CML document fails.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CmlError {
38    /// An atom referenced `elementType` that is not a known element symbol.
39    UnknownElement(String),
40    /// A `<bond>` element referenced an atom id that was not defined.
41    UnknownAtomRef(String),
42    /// A `<bond>` element's `atomRefs2` attribute was not two space-separated ids.
43    InvalidAtomRefs2(String),
44    /// A `<bond>` element's `order` attribute could not be interpreted.
45    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// ---------------------------------------------------------------------------
62// XML attribute scanner (no external dependency)
63// ---------------------------------------------------------------------------
64
65/// Extract all `key="value"` pairs from a single XML tag line.
66#[doc(hidden)]
67///
68/// Handles the most common cases:
69/// - Double-quoted values
70/// - Basic XML entities: `&amp;` `&lt;` `&gt;` `&quot;` `&apos;`
71/// - Ignores self-closing slash and angle brackets
72pub(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        // Skip whitespace and punctuation between attributes.
80        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        // Read attribute key: stop at '=' or whitespace.
88        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        // Expect '='
99        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        // Expect opening '"' or '\''
108        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        // Read until closing quote.
122        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        } // consume closing quote
131
132        if !key.is_empty() {
133            map.insert(key, value);
134        }
135    }
136    map
137}
138
139/// Decode basic XML character entities.
140fn unescape_xml(s: &str) -> String {
141    if !s.contains('&') {
142        return s.to_string();
143    }
144    s.replace("&amp;", "&")
145        .replace("&lt;", "<")
146        .replace("&gt;", ">")
147        .replace("&quot;", "\"")
148        .replace("&apos;", "'")
149}
150
151// ---------------------------------------------------------------------------
152// CML bond order decoding
153// ---------------------------------------------------------------------------
154
155fn 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        // Aromatic bonds: store as single (aromaticity is perceived from topology)
162        "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
171// ---------------------------------------------------------------------------
172// Parser
173// ---------------------------------------------------------------------------
174
175/// Intermediate atom data before building the `Molecule`.
176struct 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
186/// Parse a CML document into a `(Molecule, 2D-coords)` pair.
187///
188/// The second element of the tuple contains one `(x, y)` entry per atom in
189/// atom-insertion order.  If a molecule has no `x2`/`y2` attributes, the
190/// coordinate pairs default to `(0.0, 0.0)`.
191/// Parse a Chemical Markup Language (CML) string into a molecule and 2D coordinates.
192///
193/// **Coordinate system:** The returned `coords` use **chemical Y-up convention**
194/// (Y increases upward, matching mathematical axes). This differs from SVG/screen Y-down.
195/// When rendering via [`crate::svg::render_svg`] or similar, callers must negate Y coordinates
196/// to match SVG pixel space.
197///
198/// # Example
199/// ```text
200/// // CML molecule with chemical Y-up coords
201/// let (mol, coords) = parse_cml(cml_str)?;
202/// // coords[i].1 increases upward (chemical convention)
203///
204/// // To render in SVG (Y-down):
205/// let svg_coords: Vec<(f64, f64)> = coords.iter()
206///     .map(|(x, y)| (x, -y))  // Negate Y
207///     .collect();
208/// ```
209pub fn parse_cml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CmlError> {
210    // --- Pass 1: collect atoms and bonds from the CML -----------------------
211
212    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    // --- Pass 2: mark aromatic atoms, then build Molecule -------------------
302
303    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
340/// True if `line` starts the opening tag for the given element name
341/// (case-insensitive on the element name, as some writers vary case).
342fn 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
356// ---------------------------------------------------------------------------
357// Writer
358// ---------------------------------------------------------------------------
359
360/// Serialise a molecule to a CML string.
361///
362/// `coords` — optional slice of `(x, y)` pairs in atom-insertion order.
363/// If `None`, no coordinate attributes are written.
364pub 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// ---------------------------------------------------------------------------
421// Tests
422// ---------------------------------------------------------------------------
423
424#[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}