Skip to main content

document_svg/document/
mol2.rs

1//! Bounded Tripos MOL2 chemical structure preview.
2//!
3//! Parses MOLECULE/ATOM/BOND sections and reuses the bounded molecule renderer.
4//! Charges, substructures, force-field metadata, and unsupported molecule
5//! annotations remain inert.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::molfile::{Atom, Bond, Molecule, render_molecule};
12use crate::error::{Error, Result};
13
14const MAX_MOL2_INPUT_BYTES: u64 = 64 * 1024 * 1024;
15const MAX_MOL2_LINES: usize = 1_000_000;
16const MAX_MOL2_LINE_BYTES: usize = 1024 * 1024;
17const MAX_MOL2_MOLECULES: usize = 10_000;
18const MAX_MOL2_ATOMS: usize = 100_000;
19const MAX_MOL2_BONDS: usize = 200_000;
20const MAX_MOL2_TOTAL_ATOMS: usize = 500_000;
21
22pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
23    String::from_utf8_lossy(bytes)
24        .lines()
25        .take(32)
26        .any(|line| line.trim().eq_ignore_ascii_case("@<TRIPOS>MOLECULE"))
27}
28
29pub(crate) fn convert(
30    path: &Path,
31    options: &ConvertOptions,
32    sink: &mut dyn PageConsumer,
33) -> Result<Vec<String>> {
34    let bytes = read_limited_file(
35        path,
36        options.max_input_bytes.min(MAX_MOL2_INPUT_BYTES),
37        "MOL2 input",
38    )?;
39    let text = std::str::from_utf8(&bytes).map_err(|error| {
40        Error::InvalidInput(format!("MOL2 input is not UTF-8 or ASCII: {error}"))
41    })?;
42    let (molecules, mut warnings) = parse_molecules(text)?;
43    if molecules.is_empty() {
44        return Err(Error::InvalidInput(
45            "MOL2 contains no MOLECULE sections".into(),
46        ));
47    }
48    if molecules.len() > options.max_pages {
49        return Err(Error::LimitExceeded(format!(
50            "MOL2 contains {} molecules but max_pages is {}",
51            molecules.len(),
52            options.max_pages
53        )));
54    }
55    for (index, molecule) in molecules.iter().enumerate() {
56        let page = render_molecule(molecule, index + 1, "mol2")?;
57        warnings.extend(page.warnings.iter().cloned());
58        sink.consume(page)?;
59    }
60    warnings.sort();
61    warnings.dedup();
62    Ok(warnings)
63}
64
65fn parse_molecules(text: &str) -> Result<(Vec<Molecule>, Vec<String>)> {
66    let lines = text.lines().collect::<Vec<_>>();
67    let mut molecules = Vec::new();
68    let mut warnings = Vec::new();
69    let mut index = 0usize;
70    let mut total_atoms = 0usize;
71    while index < lines.len() {
72        if index >= MAX_MOL2_LINES {
73            return Err(Error::LimitExceeded(format!(
74                "MOL2 exceeds {MAX_MOL2_LINES} lines"
75            )));
76        }
77        if lines[index].len() > MAX_MOL2_LINE_BYTES {
78            return Err(Error::LimitExceeded(format!(
79                "MOL2 line exceeds {MAX_MOL2_LINE_BYTES} bytes"
80            )));
81        }
82        if !lines[index]
83            .trim()
84            .eq_ignore_ascii_case("@<TRIPOS>MOLECULE")
85        {
86            index += 1;
87            continue;
88        }
89        index += 1;
90        let title = lines
91            .get(index)
92            .map(|line| line.trim().to_owned())
93            .unwrap_or_default();
94        index += 1;
95        let counts = lines
96            .get(index)
97            .unwrap_or(&"")
98            .split_whitespace()
99            .collect::<Vec<_>>();
100        let atom_count = counts
101            .first()
102            .and_then(|value| value.parse::<usize>().ok())
103            .unwrap_or(0);
104        let bond_count = counts
105            .get(1)
106            .and_then(|value| value.parse::<usize>().ok())
107            .unwrap_or(0);
108        if atom_count > MAX_MOL2_ATOMS || bond_count > MAX_MOL2_BONDS {
109            return Err(Error::LimitExceeded(format!(
110                "MOL2 molecule exceeds {MAX_MOL2_ATOMS} atoms or {MAX_MOL2_BONDS} bonds"
111            )));
112        }
113        index += 1;
114        while index < lines.len() && !lines[index].trim().eq_ignore_ascii_case("@<TRIPOS>ATOM") {
115            index += 1;
116        }
117        if index >= lines.len() {
118            return Err(Error::InvalidInput(
119                "MOL2 molecule has no ATOM section".into(),
120            ));
121        }
122        index += 1;
123        let mut molecule = Molecule {
124            title,
125            atoms: Vec::with_capacity(atom_count),
126            bonds: Vec::with_capacity(bond_count),
127            warnings: Vec::new(),
128        };
129        let mut id_map = HashMap::new();
130        while index < lines.len() && !lines[index].trim_start().starts_with("@<TRIPOS>") {
131            let fields = lines[index].split_whitespace().collect::<Vec<_>>();
132            if fields.len() >= 6 {
133                let id = fields[0]
134                    .parse::<i64>()
135                    .map_err(|_| Error::InvalidInput("MOL2 atom id is invalid".into()))?;
136                let x = fields[2]
137                    .parse::<f64>()
138                    .map_err(|_| Error::InvalidInput("MOL2 atom x is invalid".into()))?;
139                let y = fields[3]
140                    .parse::<f64>()
141                    .map_err(|_| Error::InvalidInput("MOL2 atom y is invalid".into()))?;
142                let element = clean_element(fields[5]);
143                id_map.insert(id, molecule.atoms.len());
144                molecule.atoms.push(Atom {
145                    x,
146                    y,
147                    element,
148                    charge: 0,
149                    isotope: None,
150                });
151            }
152            index += 1;
153        }
154        total_atoms = total_atoms.saturating_add(molecule.atoms.len());
155        if total_atoms > MAX_MOL2_TOTAL_ATOMS {
156            return Err(Error::LimitExceeded(format!(
157                "MOL2 exceeds {MAX_MOL2_TOTAL_ATOMS} total atoms"
158            )));
159        }
160        while index < lines.len() && !lines[index].trim().eq_ignore_ascii_case("@<TRIPOS>BOND") {
161            index += 1;
162        }
163        if index < lines.len() {
164            index += 1;
165            while index < lines.len() && !lines[index].trim_start().starts_with("@<TRIPOS>") {
166                let fields = lines[index].split_whitespace().collect::<Vec<_>>();
167                if fields.len() >= 4 {
168                    let from = fields[1]
169                        .parse::<i64>()
170                        .ok()
171                        .and_then(|id| id_map.get(&id).copied());
172                    let to = fields[2]
173                        .parse::<i64>()
174                        .ok()
175                        .and_then(|id| id_map.get(&id).copied());
176                    if let (Some(from), Some(to)) = (from, to)
177                        && from != to
178                    {
179                        molecule.bonds.push(Bond {
180                            from,
181                            to,
182                            order: bond_order(fields[3]),
183                            stereo: 0,
184                        });
185                    }
186                }
187                index += 1;
188            }
189        }
190        if molecule.atoms.is_empty() {
191            warnings.push("MOL2 molecule without valid atoms was omitted".into());
192        } else {
193            if molecules.len() >= MAX_MOL2_MOLECULES {
194                return Err(Error::LimitExceeded(format!(
195                    "MOL2 exceeds {MAX_MOL2_MOLECULES} molecules"
196                )));
197            }
198            molecules.push(molecule);
199        }
200    }
201    Ok((molecules, warnings))
202}
203
204fn bond_order(value: &str) -> u8 {
205    match value.to_ascii_lowercase().as_str() {
206        "1" | "un" | "du" | "am" => 1,
207        "2" => 2,
208        "3" => 3,
209        "ar" => 4,
210        _ => 1,
211    }
212}
213
214fn clean_element(value: &str) -> String {
215    let value = value.split('.').next().unwrap_or(value);
216    let mut chars = value
217        .chars()
218        .filter(|character| character.is_ascii_alphabetic());
219    let Some(first) = chars.next() else {
220        return "X".into();
221    };
222    let mut result = first.to_ascii_uppercase().to_string();
223    if let Some(second) = chars.next() {
224        result.push(second.to_ascii_lowercase());
225    }
226    result
227}