Skip to main content

document_svg/document/
cif.rs

1//! Bounded mmCIF/PDBx atom-coordinate preview.
2//!
3//! Extracts the atom_site loop from text CIF files and renders each model with
4//! the existing molecule renderer. Chemistry perception, symmetry, and other
5//! crystallographic metadata remain inert.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::molfile::{Atom, Molecule, render_molecule};
12use crate::error::{Error, Result};
13
14const MAX_CIF_INPUT_BYTES: u64 = 64 * 1024 * 1024;
15const MAX_CIF_LINES: usize = 1_000_000;
16const MAX_CIF_LINE_BYTES: usize = 1024 * 1024;
17const MAX_CIF_TOKENS: usize = 5_000_000;
18const MAX_CIF_MODELS: usize = 1_000;
19const MAX_CIF_ATOMS_PER_MODEL: usize = 100_000;
20const MAX_CIF_TOTAL_ATOMS: usize = 500_000;
21const MAX_CIF_COORDINATE: f64 = 1_000_000.0;
22
23pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
24    String::from_utf8_lossy(bytes).lines().take(64).any(|line| {
25        line.trim_start()
26            .to_ascii_lowercase()
27            .starts_with("_atom_site.")
28    })
29}
30
31pub(crate) fn convert(
32    path: &Path,
33    options: &ConvertOptions,
34    sink: &mut dyn PageConsumer,
35) -> Result<Vec<String>> {
36    let bytes = read_limited_file(
37        path,
38        options.max_input_bytes.min(MAX_CIF_INPUT_BYTES),
39        "CIF input",
40    )?;
41    let text = std::str::from_utf8(&bytes).map_err(|error| {
42        Error::InvalidInput(format!("CIF input is not UTF-8 or ASCII: {error}"))
43    })?;
44    let (models, mut warnings) = parse_cif(text)?;
45    if models.is_empty() {
46        return Err(Error::InvalidInput(
47            "CIF contains no atom_site coordinates".into(),
48        ));
49    }
50    if models.len() > options.max_pages {
51        return Err(Error::LimitExceeded(format!(
52            "CIF contains {} models but max_pages is {}",
53            models.len(),
54            options.max_pages
55        )));
56    }
57    for (index, model) in models.values().enumerate() {
58        let page = render_molecule(model, index + 1, "cif")?;
59        warnings.extend(page.warnings.iter().cloned());
60        sink.consume(page)?;
61    }
62    warnings.sort();
63    warnings.dedup();
64    Ok(warnings)
65}
66
67fn parse_cif(text: &str) -> Result<(BTreeMap<usize, Molecule>, Vec<String>)> {
68    let lines = text.lines().collect::<Vec<_>>();
69    let mut models = BTreeMap::<usize, Molecule>::new();
70    let mut warnings = Vec::new();
71    let mut line_index = 0usize;
72    let mut token_count = 0usize;
73    let mut total_atoms = 0usize;
74    let mut title = String::new();
75    while line_index < lines.len() {
76        if line_index >= MAX_CIF_LINES {
77            return Err(Error::LimitExceeded(format!(
78                "CIF exceeds {MAX_CIF_LINES} lines"
79            )));
80        }
81        let trimmed = lines[line_index].trim();
82        if lines[line_index].len() > MAX_CIF_LINE_BYTES {
83            return Err(Error::LimitExceeded(format!(
84                "CIF line exceeds {MAX_CIF_LINE_BYTES} bytes"
85            )));
86        }
87        if trimmed.to_ascii_lowercase().starts_with("data_") && title.is_empty() {
88            title = trimmed[5..].trim().chars().take(160).collect();
89        }
90        if trimmed.eq_ignore_ascii_case("loop_") {
91            line_index += 1;
92            let mut columns = Vec::new();
93            while line_index < lines.len() && lines[line_index].trim_start().starts_with('_') {
94                columns.push(
95                    lines[line_index]
96                        .split_whitespace()
97                        .next()
98                        .unwrap_or_default()
99                        .to_owned(),
100                );
101                line_index += 1;
102            }
103            if columns.is_empty() {
104                continue;
105            }
106            let atom_loop = columns
107                .iter()
108                .any(|column| column.to_ascii_lowercase().starts_with("_atom_site."));
109            let mut row_tokens = Vec::<String>::new();
110            while line_index < lines.len() {
111                let candidate = lines[line_index].trim();
112                if is_cif_control(candidate) && row_tokens.is_empty() {
113                    break;
114                }
115                if candidate.is_empty() || candidate.starts_with('#') {
116                    line_index += 1;
117                    continue;
118                }
119                let tokens = tokenize_cif_line(lines[line_index]);
120                token_count = token_count.saturating_add(tokens.len());
121                if token_count > MAX_CIF_TOKENS {
122                    return Err(Error::LimitExceeded(format!(
123                        "CIF exceeds {MAX_CIF_TOKENS} tokens"
124                    )));
125                }
126                row_tokens.extend(tokens);
127                while row_tokens.len() >= columns.len() {
128                    let row = row_tokens.drain(..columns.len()).collect::<Vec<_>>();
129                    if atom_loop {
130                        process_atom_row(
131                            &columns,
132                            &row,
133                            &mut models,
134                            &title,
135                            &mut warnings,
136                            &mut total_atoms,
137                        )?;
138                    }
139                }
140                line_index += 1;
141            }
142            if atom_loop && !row_tokens.is_empty() {
143                push_warning_once(
144                    &mut warnings,
145                    "CIF atom_site loop ended with an incomplete row",
146                );
147            }
148            continue;
149        }
150        line_index += 1;
151    }
152    Ok((models, warnings))
153}
154
155fn process_atom_row(
156    columns: &[String],
157    row: &[String],
158    models: &mut BTreeMap<usize, Molecule>,
159    title: &str,
160    warnings: &mut Vec<String>,
161    total_atoms: &mut usize,
162) -> Result<()> {
163    let value = |name: &str| {
164        columns
165            .iter()
166            .position(|column| column.eq_ignore_ascii_case(name))
167            .and_then(|index| row.get(index))
168            .map(String::as_str)
169    };
170    let (Some(x_text), Some(y_text), Some(z_text)) = (
171        value("_atom_site.Cartn_x"),
172        value("_atom_site.Cartn_y"),
173        value("_atom_site.Cartn_z"),
174    ) else {
175        return Err(Error::InvalidInput(
176            "CIF atom_site loop is missing Cartn_x/Cartn_y/Cartn_z".into(),
177        ));
178    };
179    if is_missing(x_text) || is_missing(y_text) || is_missing(z_text) {
180        push_warning_once(warnings, "CIF atoms with missing coordinates were omitted");
181        return Ok(());
182    }
183    let x = parse_coordinate(x_text, "x")?;
184    let y = parse_coordinate(y_text, "y")?;
185    let _z = parse_coordinate(z_text, "z")?;
186    let model_number = value("_atom_site.pdbx_PDB_model_num")
187        .and_then(|value| value.parse::<usize>().ok())
188        .unwrap_or(1);
189    if model_number == 0 || model_number > MAX_CIF_MODELS {
190        return Err(Error::LimitExceeded(format!(
191            "CIF model exceeds {MAX_CIF_MODELS}"
192        )));
193    }
194    let alt = value("_atom_site.label_alt_id").unwrap_or(".");
195    if !matches!(alt, "." | "?" | "" | "A") {
196        push_warning_once(
197            warnings,
198            "CIF alternate atom locations other than blank/A were omitted",
199        );
200        return Ok(());
201    }
202    let element = value("_atom_site.type_symbol")
203        .filter(|value| !is_missing(value))
204        .map(clean_element)
205        .unwrap_or_else(|| "X".into());
206    let molecule = models.entry(model_number).or_insert_with(|| Molecule {
207        title: if title.is_empty() {
208            format!("CIF model {model_number}")
209        } else {
210            format!("{title} (model {model_number})")
211        },
212        atoms: Vec::new(),
213        bonds: Vec::new(),
214        warnings: Vec::new(),
215    });
216    if molecule.atoms.len() >= MAX_CIF_ATOMS_PER_MODEL {
217        return Err(Error::LimitExceeded(format!(
218            "CIF model exceeds {MAX_CIF_ATOMS_PER_MODEL} atoms"
219        )));
220    }
221    molecule.atoms.push(Atom {
222        x,
223        y,
224        element,
225        charge: 0,
226        isotope: None,
227    });
228    molecule.warnings.push(
229        "CIF 3D coordinates are projected onto the XY plane; bond inference is not performed"
230            .into(),
231    );
232    *total_atoms = total_atoms.saturating_add(1);
233    if *total_atoms > MAX_CIF_TOTAL_ATOMS {
234        return Err(Error::LimitExceeded(format!(
235            "CIF exceeds {MAX_CIF_TOTAL_ATOMS} total atoms"
236        )));
237    }
238    Ok(())
239}
240
241fn tokenize_cif_line(line: &str) -> Vec<String> {
242    let mut tokens = Vec::new();
243    let mut current = String::new();
244    let mut quote = None::<char>;
245    for character in line.chars() {
246        if let Some(delimiter) = quote {
247            if character == delimiter {
248                quote = None;
249            } else {
250                current.push(character);
251            }
252        } else if character == '\'' || character == '"' {
253            quote = Some(character);
254        } else if character.is_whitespace() {
255            if !current.is_empty() {
256                tokens.push(std::mem::take(&mut current));
257            }
258        } else if character == '#' && current.is_empty() {
259            break;
260        } else {
261            current.push(character);
262        }
263    }
264    if !current.is_empty() {
265        tokens.push(current);
266    }
267    tokens
268}
269
270fn is_cif_control(line: &str) -> bool {
271    let lower = line.to_ascii_lowercase();
272    line.starts_with('_')
273        || lower == "loop_"
274        || lower.starts_with("data_")
275        || lower.starts_with("save_")
276}
277
278fn parse_coordinate(value: &str, label: &str) -> Result<f64> {
279    let value = value.trim_matches(|character| character == '(' || character == ')');
280    let coordinate = value
281        .parse::<f64>()
282        .map_err(|_| Error::InvalidInput(format!("CIF {label} coordinate is invalid")))?;
283    if !coordinate.is_finite() || coordinate.abs() > MAX_CIF_COORDINATE {
284        return Err(Error::InvalidInput(format!(
285            "CIF {label} coordinate exceeds ±{MAX_CIF_COORDINATE}"
286        )));
287    }
288    Ok(coordinate)
289}
290
291fn clean_element(value: &str) -> String {
292    let mut chars = value
293        .chars()
294        .filter(|character| character.is_ascii_alphabetic());
295    let Some(first) = chars.next() else {
296        return "X".into();
297    };
298    let mut result = first.to_ascii_uppercase().to_string();
299    if let Some(second) = chars.next() {
300        result.push(second.to_ascii_lowercase());
301    }
302    result
303}
304
305fn is_missing(value: &str) -> bool {
306    matches!(value.trim(), "." | "?") || value.trim().is_empty()
307}
308
309fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
310    if !warnings.iter().any(|existing| existing == warning) {
311        warnings.push(warning.to_owned());
312    }
313}