Skip to main content

molframe_pdb/writer/
pdb.rs

1//! Writing the legacy fixed-column format, or refusing to.
2//!
3//! The format's fields have fixed widths, and a structure can outgrow every one
4//! of them: a chain label of two characters, a serial past ninety-nine thousand,
5//! a residue number past ten thousand, a coordinate too large for eight columns.
6//!
7//! Every one of those is checked before a single byte is written, and a
8//! structure that does not fit is refused with a diagnostic naming the field and
9//! offering a way out. Truncating a chain label from `AA` to `A` produces a file
10//! that opens cleanly in any viewer and describes a different molecule, which is
11//! the failure this library exists to prevent.
12
13use super::capacity::{RequiredAtomFields, check_capacity, required_text, required_value};
14use crate::hybrid36;
15use crate::{PDB_HEADERS_EXTENSION, PdbHeaders};
16use molframe_core::diagnostic::Diagnostic;
17use molframe_core::io::{Select, SelectAll};
18use molframe_core::structure::{AtomRef, ChainRef, ResidueRef, Structure};
19use std::collections::HashMap;
20use std::fmt;
21
22#[path = "identity.rs"]
23mod identity;
24pub use identity::PdbIdentifierNamespace;
25pub(crate) use identity::{atom_name, chain_name, component_name, residue_sequence};
26
27/// How to write, and what to do about a structure that does not fit.
28#[derive(Clone, Debug)]
29pub struct PdbOptions {
30    /// Rename chains on the way out, so a structure with labels the format
31    /// cannot hold can still be written deliberately.
32    chain_map: HashMap<Box<str>, Box<str>>,
33    /// Write serials and residue numbers past the decimal range in hybrid-36.
34    ///
35    /// Off by default: a consumer that does not implement the scheme would read
36    /// a wrong number without noticing, so it is offered rather than assumed.
37    hybrid36: bool,
38    /// Identifier namespace used consistently for every deposited identity.
39    namespace: PdbIdentifierNamespace,
40}
41
42impl Default for PdbOptions {
43    fn default() -> Self {
44        Self {
45            chain_map: HashMap::new(),
46            hybrid36: false,
47            namespace: PdbIdentifierNamespace::Label,
48        }
49    }
50}
51
52impl PdbOptions {
53    /// Options that refuse anything the format cannot hold.
54    #[must_use]
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Renames one chain on the way out.
60    #[must_use]
61    pub fn chain_map(mut self, from: impl Into<Box<str>>, to: impl Into<Box<str>>) -> Self {
62        self.chain_map.insert(from.into(), to.into());
63        self
64    }
65
66    /// Writes counts past the decimal range in hybrid-36 rather than refusing.
67    #[must_use]
68    pub const fn hybrid36(mut self, enabled: bool) -> Self {
69        self.hybrid36 = enabled;
70        self
71    }
72
73    /// Selects one identifier namespace for chains, residues and atoms.
74    #[must_use]
75    pub const fn namespace(mut self, namespace: PdbIdentifierNamespace) -> Self {
76        self.namespace = namespace;
77        self
78    }
79
80    pub(crate) const fn identifier_namespace(&self) -> PdbIdentifierNamespace {
81        self.namespace
82    }
83
84    pub(crate) const fn uses_hybrid36(&self) -> bool {
85        self.hybrid36
86    }
87
88    /// The label a chain is written under.
89    pub(crate) fn label_for<'a>(&'a self, label: &'a str) -> &'a str {
90        match self.chain_map.get(label) {
91            Some(replacement) => replacement,
92            None => label,
93        }
94    }
95}
96
97/// Writes a structure, or explains why it cannot be written.
98///
99/// # Errors
100///
101/// Returns every capacity the structure exceeds, so a caller sees the whole
102/// problem rather than fixing one field at a time.
103pub fn write(structure: &Structure, options: &PdbOptions) -> Result<String, Vec<Diagnostic>> {
104    write_selected(structure, options, &SelectAll)
105}
106
107/// Writes the parts of a structure a filter accepts.
108///
109/// # Errors
110///
111/// Returns every capacity the accepted parts exceed.
112pub fn write_selected(
113    structure: &Structure,
114    options: &PdbOptions,
115    select: &impl Select,
116) -> Result<String, Vec<Diagnostic>> {
117    let mut out = String::with_capacity(structure.atom_count() as usize * 81);
118    render_selected(&mut out, structure, options, select)?;
119    Ok(out)
120}
121
122pub(super) fn render_selected(
123    out: &mut impl fmt::Write,
124    structure: &Structure,
125    options: &PdbOptions,
126    select: &impl Select,
127) -> Result<(), Vec<Diagnostic>> {
128    let mut refusals = check_capacity(structure, options, select, RequiredAtomFields::Pdb);
129    refusals.extend(super::secondary::preflight(structure, options, select));
130    if !refusals.is_empty() {
131        return Err(refusals);
132    }
133
134    let data = structure.data();
135
136    if let Some(headers) = data.extensions.get::<PdbHeaders>(PDB_HEADERS_EXTENSION) {
137        for record in headers.records() {
138            if matches!(record.name(), "HELIX" | "SHEET")
139                && structure
140                    .secondary_structure()
141                    .iter()
142                    .any(|state| state.is_helix() || state.is_strand())
143            {
144                continue;
145            }
146            let _ = writeln!(out, "{}", record.line());
147        }
148    } else {
149        write_generated_metadata(out, structure);
150    }
151    super::secondary::write(out, structure, options, select);
152
153    let mut serial = 1i64;
154    for chain in data.chains() {
155        if !select.accept_chain(chain.index()) {
156            continue;
157        }
158        let label = chain_label(&chain, options.namespace);
159        let label = options.label_for(label);
160
161        for residue in chain.residues() {
162            if !select.accept_residue(residue.index()) {
163                continue;
164            }
165            for atom in residue.atoms() {
166                if !select.accept_atom(atom.index()) {
167                    continue;
168                }
169                if let Err(finding) =
170                    write_atom(out, structure, &atom, &residue, label, serial, options)
171                {
172                    return Err(vec![finding]);
173                }
174                serial += 1;
175            }
176        }
177        let _ = writeln!(out, "TER   {:>5}", serial_field(serial, options));
178        serial += 1;
179    }
180    let _ = out.write_str("END\n");
181    Ok(())
182}
183
184fn write_generated_metadata(out: &mut impl fmt::Write, structure: &Structure) {
185    let data = structure.data();
186    if let Some(id) = &data.entry.id {
187        let _ = writeln!(out, "HEADER    {:<40}{:<9}   {:>4}", "", "", id);
188    }
189    if let Some(title) = &data.entry.title {
190        let _ = writeln!(out, "TITLE     {title}");
191    }
192    if let Some(method) = &data.entry.method {
193        let _ = writeln!(out, "EXPDTA    {method}");
194    }
195    if let Some(cell) = data.cell {
196        let _ = writeln!(
197            out,
198            "CRYST1{:9.3}{:9.3}{:9.3}{:7.2}{:7.2}{:7.2} {:<11}",
199            cell.lengths[0],
200            cell.lengths[1],
201            cell.lengths[2],
202            cell.angles[0],
203            cell.angles[1],
204            cell.angles[2],
205            // CRYST1 names P 1 when the entry records no space group.
206            match data.entry.space_group.as_deref() {
207                Some(symbol) => symbol,
208                None => "P 1",
209            },
210        );
211    }
212}
213
214fn write_atom(
215    out: &mut impl fmt::Write,
216    structure: &Structure,
217    atom: &AtomRef<'_>,
218    residue: &ResidueRef<'_>,
219    chain: &str,
220    serial: i64,
221    options: &PdbOptions,
222) -> Result<(), Diagnostic> {
223    let record = if residue.is_het() { "HETATM" } else { "ATOM  " };
224    let name = required_text(
225        atom_name(*atom, options.namespace),
226        "atom identifier",
227        *atom,
228        options,
229    )?;
230    let element = match atom.element() {
231        Some(element) => element.symbol(),
232        None => "",
233    };
234    // An atom whose position was never recorded still has a row here, but the
235    // format has no way to say "unrecorded" — so it is left out of the file
236    // rather than written as an origin the experiment never observed.
237    let position = required_value(atom.position(), "coordinates", *atom, options)?;
238    let seq = i64::from(required_value(
239        residue_sequence(*residue, options.namespace),
240        "residue sequence identifier",
241        *atom,
242        options,
243    )?);
244    let component = required_text(
245        component_name(*atom, *residue, options.namespace),
246        "component identifier",
247        *atom,
248        options,
249    )?;
250    let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
251    let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
252
253    let _ = writeln!(
254        out,
255        "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1}   \
256         {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2}          {element:>2}",
257        serial = serial_field(serial, options),
258        name = atom_name_field(name),
259        alt = alt_field(structure, atom),
260        comp = component,
261        seq = residue_field(seq, options),
262        ins = insertion_code(*residue),
263        x = f64::from(position[0]),
264        y = f64::from(position[1]),
265        z = f64::from(position[2]),
266        occ = f64::from(occupancy),
267        b = f64::from(b_factor),
268    );
269    // A tensor recorded for this atom follows under the same serial and the
270    // same identity columns, as the record pairs by serial.
271    if let Some(u) = structure.data().anisotropy.for_atom(atom.index()) {
272        super::aniso::Record {
273            atom,
274            structure,
275            residue,
276            name,
277            component,
278            chain,
279            seq: &residue_field(seq, options),
280            serial: &serial_field(serial, options),
281            u: &u,
282        }
283        .write(out);
284    }
285    Ok(())
286}
287
288/// The four-column name field.
289///
290/// A name shorter than four characters starts in the second column, which is the
291/// convention that lets a reader tell a one-letter element from a two-letter one.
292pub(crate) fn atom_name_field(name: &str) -> String {
293    if name.len() >= 4 {
294        name.to_owned()
295    } else {
296        format!(" {name:<3}")
297    }
298}
299
300/// The one-column alternate-location field.
301///
302/// A label longer than one character has already been refused by the capacity
303/// check, so anything reaching here fits.
304pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
305    let Some(alt) = atom.alt_id() else { return " " };
306    let Some(symbol) = alt.symbol() else {
307        return " ";
308    };
309    match structure.resolve(symbol) {
310        Some(label) => label,
311        None => " ",
312    }
313}
314
315pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
316    match options
317        .hybrid36
318        .then(|| hybrid36::encode(serial, 5))
319        .flatten()
320    {
321        Some(field) => field,
322        None => format!("{serial:>5}"),
323    }
324}
325
326pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
327    match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
328        Some(field) => field,
329        None => format!("{seq:>4}"),
330    }
331}
332
333pub(crate) fn chain_label<'a>(
334    chain: &'a ChainRef<'a>,
335    namespace: PdbIdentifierNamespace,
336) -> &'a str {
337    let Some(name) = chain_name(*chain, namespace) else {
338        return "";
339    };
340    name
341}
342
343pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
344    let Some(code) = residue.ins_code() else {
345        return " ";
346    };
347    code
348}
349
350#[cfg(test)]
351#[path = "pdb_tests.rs"]
352mod tests;