Skip to main content

chematic_mol/
cdxml.rs

1//! ChemDraw XML (CDXML) parser and writer.
2//!
3//! CDXML is a proprietary XML format produced by ChemDraw (PerkinElmer /
4//! Revvity).  This module handles the minimal subset needed to read and
5//! write molecular structure through 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_cdxml`] targets **self-round-trip** correctness (this parser
21//!   can read what this writer produces), not full ChemDraw-application
22//!   compatibility — CDXML is a proprietary format and writing files
23//!   ChemDraw itself will accept requires undocumented attributes.
24//! - Wedge/hash bond stereo (tetrahedral) is derived from `Display` attribute.
25//! - E/Z double-bond stereo is derived from 2D coordinates via `assign_ez_from_2d`.
26//! - Presentation-only nodes (text boxes, arrows, etc.) are silently skipped.
27
28use 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// ---------------------------------------------------------------------------
36// Error type
37// ---------------------------------------------------------------------------
38
39/// Error returned when parsing a CDXML document fails.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum CdxmlError {
42    /// An atom `Element` attribute contained an unknown atomic number.
43    UnknownAtomicNumber(u32),
44    /// A bond referenced an atom id that was not defined.
45    UnknownAtomRef(String),
46    /// A `<b>` bond element is missing a `B` or `E` attribute.
47    MissingBondEndpoint,
48    /// The `p` coordinate attribute could not be parsed.
49    InvalidCoords(String),
50    /// The document contains more atoms than the parser's safety limit.
51    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
68// ---------------------------------------------------------------------------
69// Parser
70// ---------------------------------------------------------------------------
71
72/// Parse a CDXML document and return the first molecular fragment.
73///
74/// Convenience wrapper around [`parse_cdxml_all`].
75/// Parse a ChemDraw CDXML string into a molecule and 2D coordinates.
76///
77/// **Coordinate system:** The returned `coords` use **ChemDraw Y-down convention**
78/// (Y increases downward, matching screen/SVG pixel space). No Y-axis conversion is required
79/// for SVG rendering; coordinates can be used directly in [`crate::svg::render_svg`] or similar.
80pub 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/// Parse all molecular fragments from a CDXML document.
89///
90/// Each `<fragment>` element in the document is parsed as a separate
91/// molecule.  Returns a `Vec` of `(Molecule, 2D-coords)` pairs in document
92/// order.  Coordinates are in CDXML point units (1/72 inch).
93///
94/// # Stereochemistry
95///
96/// Wedge bonds are derived from the `Display` attribute of `<b>` elements:
97/// `"WedgeBegin"` / `"WedgedHashBegin"` → [`BondOrder::Up`];
98/// `"Hash"` / `"Dash"` / `"WedgeEnd"` / `"WedgedHashEnd"` → [`BondOrder::Down`].
99/// Accumulator for a single CDXML `<fragment>` being parsed.
100#[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        // Derive E/Z stereo from 2D atom positions (RDKit issue #9356: CDXML loses E/Z).
159        assign_ez_from_2d(&mut mol, &coords);
160        results.push((mol, coords));
161        *self = FragAccum::default();
162        Ok(())
163    }
164}
165
166/// Maximum atoms allowed in a single CDXML fragment (DoS guard).
167pub 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                // "1.5" is used by some CDXML writers (e.g. OpenBabel) for
255                // aromatic bonds.  Store as Aromatic so that aromaticity
256                // perception is not required to recover the correct bond type.
257                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    // Handle documents without explicit </fragment> closing tags.
278    acc.flush(&mut results)?;
279
280    Ok(results)
281}
282
283/// True if `line` starts a CDXML `<n` atom node tag.
284fn is_n_tag(line: &str) -> bool {
285    (line.starts_with("<n ") || line.starts_with("<n\t") || line == "<n>")
286        && !line.starts_with("<node") // avoid accidental match on <node>
287}
288
289/// True if `line` starts a CDXML `<b` bond tag.
290fn 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
298// ---------------------------------------------------------------------------
299// Writer
300// ---------------------------------------------------------------------------
301
302/// Serialise `mol` to a minimal CDXML document that [`parse_cdxml`] can read
303/// back. See the module docs for the self-round-trip scope of this writer.
304///
305/// `coords[i]` is the `(x, y)` position for atom `i`, in the same
306/// **ChemDraw Y-down convention** `parse_cdxml` produces. Atoms beyond
307/// `coords.len()` receive `(0.0, 0.0)`.
308pub 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        // Wedge/hash stereo is Single order + Display attribute (mirrors the
336        // parser's decoding at is_b_tag handling above); other orders map
337        // straight to the Order attribute and ignore Display.
338        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// ---------------------------------------------------------------------------
361// Tests
362// ---------------------------------------------------------------------------
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    // Minimal hand-crafted CDXML for ethanol (C-C-O) with 2 bonds.
369    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        // Atom 1 (first C): p="10.0 20.0"
401        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        // Element=300 would silently truncate to 44 (Ru) via u32 as u8.
461        // Must be caught as UnknownAtomicNumber before the cast.
462        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    // B4: multi-fragment CDXML document tests
473
474    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        // First fragment atom 0 at x=10
518        assert!((coords0[0].0 - 10.0).abs() < 0.01);
519        // Second fragment atom 0 at x=60
520        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    // -----------------------------------------------------------------------
531    // Order="1.5" aromatic bond (OpenBabel / some CDXML writers)
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn parse_cdxml_aromatic_bond_order_1_5() {
536        // Some CDXML producers (e.g. tools derived from OpenBabel) write
537        // aromatic bonds as Order="1.5".  These must be stored as
538        // BondOrder::Aromatic rather than falling through to Single.
539        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        // Benzene written with all Order="1.5" bonds.
552        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    // -----------------------------------------------------------------------
574    // write_cdxml round-trip
575    // -----------------------------------------------------------------------
576
577    #[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}