document_svg/document/
cif.rs1use 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}