Skip to main content

openproteo_core/
mzml.rs

1//! Canonical mzML 1.1.0 writer.
2//!
3//! Consumes any [`SpectrumSource`] and emits valid mzML, with an indexed
4//! variant that also writes the `<indexList>` + `<fileChecksum>` trailer so
5//! random-access mzML readers (pyteomics, pymzml, sciex-style indexers) work.
6//!
7//! The implementation here is the vendor-neutral lift of the writer that
8//! originally lived in `opentfraw::mzml`. All vendor-specific decisions (the
9//! source-file CV term, the native-ID format CV term, the instrument CV term,
10//! the per-scan `native_id` strings) come from the source's
11//! [`RunMetadata`](crate::RunMetadata) and from each
12//! [`SpectrumRecord::native_id`](crate::SpectrumRecord).
13
14use std::io::{Result, Write};
15
16use crate::enums::{Activation, Analyzer, MobilityArrayKind, Polarity, ScanMode};
17use crate::source::SpectrumSource;
18use crate::types::{CvTerm, RunMetadata, SpectrumRecord};
19
20// ---------- byte-counting writer that also feeds a streaming SHA-1 ----------
21
22struct CountingWriter<'a, W: Write> {
23    inner: &'a mut W,
24    pos: u64,
25    sha1: Sha1,
26    hashing: bool,
27}
28
29impl<'a, W: Write> CountingWriter<'a, W> {
30    fn new(inner: &'a mut W) -> Self {
31        Self {
32            inner,
33            pos: 0,
34            sha1: Sha1::new(),
35            hashing: true,
36        }
37    }
38}
39
40impl<W: Write> Write for CountingWriter<'_, W> {
41    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
42        let n = self.inner.write(buf)?;
43        self.pos += n as u64;
44        if self.hashing {
45            self.sha1.update(&buf[..n]);
46        }
47        Ok(n)
48    }
49    fn flush(&mut self) -> std::io::Result<()> {
50        self.inner.flush()
51    }
52}
53
54// ---------- minimal SHA-1 (RFC 3174) so we don't pull in a crypto dep -------
55
56struct Sha1 {
57    state: [u32; 5],
58    count: u64,
59    buf: [u8; 64],
60    buf_len: usize,
61}
62
63impl Sha1 {
64    fn new() -> Self {
65        Self {
66            state: [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0],
67            count: 0,
68            buf: [0u8; 64],
69            buf_len: 0,
70        }
71    }
72
73    fn update(&mut self, data: &[u8]) {
74        let mut off = 0;
75        while off < data.len() {
76            let space = 64 - self.buf_len;
77            let take = space.min(data.len() - off);
78            self.buf[self.buf_len..self.buf_len + take].copy_from_slice(&data[off..off + take]);
79            self.buf_len += take;
80            self.count += take as u64;
81            off += take;
82            if self.buf_len == 64 {
83                self.compress();
84                self.buf_len = 0;
85            }
86        }
87    }
88
89    fn compress(&mut self) {
90        let mut w = [0u32; 80];
91        for (i, word) in w.iter_mut().enumerate().take(16) {
92            *word = u32::from_be_bytes(self.buf[i * 4..i * 4 + 4].try_into().unwrap());
93        }
94        for i in 16..80 {
95            w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
96        }
97        let [mut a, mut b, mut c, mut d, mut e] = self.state;
98        for (i, &wi) in w.iter().enumerate() {
99            let (f, k) = match i {
100                0..=19 => ((b & c) | (!b & d), 0x5A827999u32),
101                20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
102                40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
103                _ => (b ^ c ^ d, 0xCA62C1D6),
104            };
105            let temp = a
106                .rotate_left(5)
107                .wrapping_add(f)
108                .wrapping_add(e)
109                .wrapping_add(k)
110                .wrapping_add(wi);
111            e = d;
112            d = c;
113            c = b.rotate_left(30);
114            b = a;
115            a = temp;
116        }
117        self.state[0] = self.state[0].wrapping_add(a);
118        self.state[1] = self.state[1].wrapping_add(b);
119        self.state[2] = self.state[2].wrapping_add(c);
120        self.state[3] = self.state[3].wrapping_add(d);
121        self.state[4] = self.state[4].wrapping_add(e);
122    }
123
124    fn finalize(mut self) -> [u8; 20] {
125        let bit_count = self.count * 8;
126        self.update(&[0x80]);
127        while self.buf_len != 56 {
128            self.update(&[0u8]);
129        }
130        self.update(&bit_count.to_be_bytes());
131        let mut digest = [0u8; 20];
132        for (i, &word) in self.state.iter().enumerate() {
133            digest[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
134        }
135        digest
136    }
137}
138
139// ---------- base64 (RFC 4648 sec 4, no wrapping) ----------------------------
140
141const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
142
143fn base64_encode(data: &[u8]) -> String {
144    let n = data.len();
145    let mut out = Vec::with_capacity(n.div_ceil(3) * 4);
146    let mut i = 0;
147    while i + 2 < n {
148        let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | (data[i + 2] as u32);
149        out.push(B64[((b >> 18) & 0x3f) as usize]);
150        out.push(B64[((b >> 12) & 0x3f) as usize]);
151        out.push(B64[((b >> 6) & 0x3f) as usize]);
152        out.push(B64[(b & 0x3f) as usize]);
153        i += 3;
154    }
155    if n - i == 2 {
156        let b = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
157        out.push(B64[((b >> 18) & 0x3f) as usize]);
158        out.push(B64[((b >> 12) & 0x3f) as usize]);
159        out.push(B64[((b >> 6) & 0x3f) as usize]);
160        out.push(b'=');
161    } else if n - i == 1 {
162        let b = (data[i] as u32) << 16;
163        out.push(B64[((b >> 18) & 0x3f) as usize]);
164        out.push(B64[((b >> 12) & 0x3f) as usize]);
165        out.push(b'=');
166        out.push(b'=');
167    }
168    String::from_utf8(out).expect("base64 output is ASCII")
169}
170
171fn encode_f64_array(vals: &[f64]) -> String {
172    let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
173    base64_encode(&bytes)
174}
175
176fn encode_f32_array(vals: &[f32]) -> String {
177    let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
178    base64_encode(&bytes)
179}
180
181fn escape(s: &str) -> String {
182    s.replace('&', "&amp;")
183        .replace('<', "&lt;")
184        .replace('>', "&gt;")
185        .replace('"', "&quot;")
186        .replace('\'', "&apos;")
187}
188
189fn activation_cv(act: Activation, analyzer: Option<Analyzer>) -> (&'static str, &'static str) {
190    match act {
191        Activation::HCD => ("MS:1000422", "beam-type collision-induced dissociation"),
192        Activation::ETD | Activation::EThcD => ("MS:1000598", "electron transfer dissociation"),
193        Activation::CID => match analyzer {
194            Some(Analyzer::FTMS) => ("MS:1000422", "beam-type collision-induced dissociation"),
195            _ => ("MS:1000133", "collision-induced dissociation"),
196        },
197        Activation::MPID => (
198            "MS:1002481",
199            "supplemental beam-type collision-induced dissociation",
200        ),
201        Activation::ECD => ("MS:1000250", "electron capture dissociation"),
202        Activation::IRMPD => ("MS:1000262", "infrared multiphoton dissociation"),
203        Activation::PD => ("MS:1001880", "in-source collision-induced dissociation"),
204        Activation::PQD => ("MS:1000599", "pulsed q dissociation"),
205        Activation::UVPD => ("MS:1003246", "ultraviolet photodissociation"),
206        Activation::SID => ("MS:1000422", "beam-type collision-induced dissociation"),
207    }
208}
209
210// ---------- public entry points --------------------------------------------
211
212/// Write the source's spectra as mzML 1.1.0 (un-indexed).
213pub fn write_mzml<S: SpectrumSource + ?Sized, W: Write>(src: &mut S, out: &mut W) -> Result<()> {
214    let meta = src.run_metadata();
215    let count = src.spectrum_count_hint().unwrap_or(0);
216    let mobility_kind = meta.mobility_array_kind;
217
218    write_prologue(out, &meta, count, false)?;
219    for rec in src.iter_spectra() {
220        write_spectrum(out, &rec, mobility_kind)?;
221    }
222    writeln!(out, r#"    </spectrumList>"#)?;
223    writeln!(out, r#"  </run>"#)?;
224    writeln!(out, r#"</mzML>"#)?;
225    Ok(())
226}
227
228/// Write the source's spectra as indexed mzML 1.1.0 (with `<indexList>` and
229/// `<fileChecksum>` trailer).
230pub fn write_indexed_mzml<S: SpectrumSource + ?Sized, W: Write>(
231    src: &mut S,
232    out: &mut W,
233) -> Result<()> {
234    let meta = src.run_metadata();
235    let count = src.spectrum_count_hint().unwrap_or(0);
236    let mobility_kind = meta.mobility_array_kind;
237
238    let mut cw = CountingWriter::new(out);
239    write_prologue(&mut cw, &meta, count, true)?;
240
241    let mut offsets: Vec<(String, u64)> = Vec::with_capacity(count);
242    for rec in src.iter_spectra() {
243        offsets.push((rec.native_id.clone(), cw.pos));
244        write_spectrum(&mut cw, &rec, mobility_kind)?;
245    }
246
247    writeln!(cw, r#"    </spectrumList>"#)?;
248    writeln!(cw, r#"  </run>"#)?;
249    writeln!(cw, r#"  </mzML>"#)?;
250
251    let index_list_offset = cw.pos;
252    writeln!(cw, r#"  <indexList count="1">"#)?;
253    writeln!(cw, r#"    <index name="spectrum">"#)?;
254    for (id, offset) in &offsets {
255        writeln!(
256            cw,
257            r#"      <offset idRef="{}">{}</offset>"#,
258            escape(id),
259            offset
260        )?;
261    }
262    writeln!(cw, r#"    </index>"#)?;
263    writeln!(cw, r#"  </indexList>"#)?;
264
265    cw.hashing = false;
266    let digest = std::mem::replace(&mut cw.sha1, Sha1::new()).finalize();
267    let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
268
269    writeln!(
270        cw,
271        r#"  <indexListOffset>{}</indexListOffset>"#,
272        index_list_offset
273    )?;
274    writeln!(cw, r#"  <fileChecksum>{}</fileChecksum>"#, hex)?;
275    writeln!(cw, r#"</indexedmzML>"#)?;
276    Ok(())
277}
278
279// ---------- prologue / spectrum body ---------------------------------------
280
281fn write_prologue<W: Write>(
282    out: &mut W,
283    meta: &RunMetadata,
284    n_spectra: usize,
285    indexed: bool,
286) -> Result<()> {
287    writeln!(out, r#"<?xml version="1.0" encoding="utf-8"?>"#)?;
288    if indexed {
289        writeln!(out, r#"<indexedmzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
290        writeln!(
291            out,
292            r#"             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
293        )?;
294        writeln!(
295            out,
296            r#"             xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.2_idx.xsd">"#
297        )?;
298        writeln!(
299            out,
300            r#"  <mzML xmlns="http://psi.hupo.org/ms/mzml" version="1.1.0">"#
301        )?;
302    } else {
303        writeln!(out, r#"<mzML xmlns="http://psi.hupo.org/ms/mzml""#)?;
304        writeln!(
305            out,
306            r#"      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance""#
307        )?;
308        writeln!(
309            out,
310            r#"      xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.2_idx.xsd""#
311        )?;
312        writeln!(out, r#"      version="1.1.0">"#)?;
313    }
314
315    writeln!(out, r#"  <cvList count="2">"#)?;
316    writeln!(
317        out,
318        r#"    <cv id="MS" fullName="Proteomics Standards Initiative Mass Spectrometry Ontology" version="4.1.100" URI="https://raw.githubusercontent.com/HUPO-PSI/psi-ms-CV/master/psi-ms.obo"/>"#
319    )?;
320    writeln!(
321        out,
322        r#"    <cv id="UO" fullName="Unit Ontology" version="09:04:2014" URI="https://raw.githubusercontent.com/bio-ontology-research-group/unit-ontology/master/unit.obo"/>"#
323    )?;
324    writeln!(out, r#"  </cvList>"#)?;
325
326    writeln!(out, r#"  <fileDescription>"#)?;
327    writeln!(out, r#"    <fileContent>"#)?;
328    writeln!(
329        out,
330        r#"      <cvParam cvRef="MS" accession="MS:1000579" name="MS1 spectrum" value=""/>"#
331    )?;
332    writeln!(
333        out,
334        r#"      <cvParam cvRef="MS" accession="MS:1000580" name="MSn spectrum" value=""/>"#
335    )?;
336    writeln!(out, r#"    </fileContent>"#)?;
337    writeln!(out, r#"    <sourceFileList count="1">"#)?;
338    writeln!(
339        out,
340        r#"      <sourceFile id="sf1" name="{}" location="">"#,
341        escape(&meta.source_file_name)
342    )?;
343    write_cv(out, "        ", &meta.source_file_format)?;
344    write_cv(out, "        ", &meta.native_id_format)?;
345    writeln!(out, r#"      </sourceFile>"#)?;
346    writeln!(out, r#"    </sourceFileList>"#)?;
347    writeln!(out, r#"  </fileDescription>"#)?;
348
349    writeln!(out, r#"  <softwareList count="1">"#)?;
350    writeln!(
351        out,
352        r#"    <software id="{}" version="{}">"#,
353        escape(&meta.software_name),
354        escape(&meta.software_version)
355    )?;
356    writeln!(
357        out,
358        r#"      <cvParam cvRef="MS" accession="MS:1000799" name="custom unreleased software tool" value="{}"/>"#,
359        escape(&meta.software_name)
360    )?;
361    writeln!(out, r#"    </software>"#)?;
362    writeln!(out, r#"  </softwareList>"#)?;
363
364    writeln!(out, r#"  <instrumentConfigurationList count="1">"#)?;
365    writeln!(out, r#"    <instrumentConfiguration id="IC1">"#)?;
366    write_cv(out, "      ", &meta.instrument)?;
367    writeln!(out, r#"    </instrumentConfiguration>"#)?;
368    writeln!(out, r#"  </instrumentConfigurationList>"#)?;
369
370    writeln!(out, r#"  <dataProcessingList count="1">"#)?;
371    writeln!(out, r#"    <dataProcessing id="dp1">"#)?;
372    writeln!(
373        out,
374        r#"      <processingMethod order="0" softwareRef="{}">"#,
375        escape(&meta.software_name)
376    )?;
377    writeln!(
378        out,
379        r#"        <cvParam cvRef="MS" accession="MS:1000544" name="Conversion to mzML" value=""/>"#
380    )?;
381    writeln!(out, r#"      </processingMethod>"#)?;
382    writeln!(out, r#"    </dataProcessing>"#)?;
383    writeln!(out, r#"  </dataProcessingList>"#)?;
384
385    writeln!(
386        out,
387        r#"  <run id="{}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1">"#,
388        escape(&meta.source_file_name)
389    )?;
390    writeln!(
391        out,
392        r#"    <spectrumList count="{}" defaultDataProcessingRef="dp1">"#,
393        n_spectra
394    )?;
395    Ok(())
396}
397
398fn write_cv<W: Write>(out: &mut W, indent: &str, cv: &CvTerm) -> Result<()> {
399    writeln!(
400        out,
401        r#"{indent}<cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
402        cv.accession,
403        escape(&cv.name)
404    )
405}
406
407fn write_spectrum<W: Write>(
408    out: &mut W,
409    rec: &SpectrumRecord,
410    mobility_kind: Option<MobilityArrayKind>,
411) -> Result<()> {
412    let spectrum_type = if rec.ms_level <= 1 {
413        ("MS:1000579", "MS1 spectrum")
414    } else {
415        ("MS:1000580", "MSn spectrum")
416    };
417    let n_peaks = rec.mz.len();
418
419    writeln!(
420        out,
421        r#"      <spectrum id="{id}" index="{idx}" defaultArrayLength="{n}">"#,
422        id = escape(&rec.native_id),
423        idx = rec.index,
424        n = n_peaks
425    )?;
426    writeln!(
427        out,
428        r#"        <cvParam cvRef="MS" accession="MS:1000511" name="ms level" value="{}"/>"#,
429        rec.ms_level
430    )?;
431    writeln!(
432        out,
433        r#"        <cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
434        spectrum_type.0, spectrum_type.1
435    )?;
436
437    match rec.scan_mode {
438        Some(ScanMode::Centroid) => writeln!(
439            out,
440            r#"        <cvParam cvRef="MS" accession="MS:1000127" name="centroid spectrum" value=""/>"#
441        )?,
442        _ => writeln!(
443            out,
444            r#"        <cvParam cvRef="MS" accession="MS:1000128" name="profile spectrum" value=""/>"#
445        )?,
446    }
447
448    match rec.polarity {
449        Some(Polarity::Positive) => writeln!(
450            out,
451            r#"        <cvParam cvRef="MS" accession="MS:1000130" name="positive scan" value=""/>"#
452        )?,
453        Some(Polarity::Negative) => writeln!(
454            out,
455            r#"        <cvParam cvRef="MS" accession="MS:1000129" name="negative scan" value=""/>"#
456        )?,
457        None => {}
458    }
459
460    let tic = rec.effective_tic();
461    let (bp_mz, bp_int) = rec.effective_base_peak().unwrap_or((0.0, 0.0));
462    let (lo_mz, hi_mz) = rec.effective_mz_range().unwrap_or((0.0, 0.0));
463
464    writeln!(
465        out,
466        r#"        <cvParam cvRef="MS" accession="MS:1000285" name="total ion current" value="{:.6}"/>"#,
467        tic
468    )?;
469    writeln!(
470        out,
471        r#"        <cvParam cvRef="MS" accession="MS:1000504" name="base peak m/z" value="{:.6}"/>"#,
472        bp_mz
473    )?;
474    writeln!(
475        out,
476        r#"        <cvParam cvRef="MS" accession="MS:1000505" name="base peak intensity" value="{:.6}"/>"#,
477        bp_int
478    )?;
479    writeln!(
480        out,
481        r#"        <cvParam cvRef="MS" accession="MS:1000528" name="lowest observed m/z" value="{:.6}"/>"#,
482        lo_mz
483    )?;
484    writeln!(
485        out,
486        r#"        <cvParam cvRef="MS" accession="MS:1000527" name="highest observed m/z" value="{:.6}"/>"#,
487        hi_mz
488    )?;
489
490    writeln!(out, r#"        <scanList count="1">"#)?;
491    writeln!(
492        out,
493        r#"          <cvParam cvRef="MS" accession="MS:1000795" name="no combination" value=""/>"#
494    )?;
495    writeln!(out, r#"          <scan>"#)?;
496
497    if let Some(f) = rec.filter.as_deref() {
498        if !f.is_empty() {
499            writeln!(
500                out,
501                r#"            <cvParam cvRef="MS" accession="MS:1000512" name="filter string" value="{}"/>"#,
502                escape(f)
503            )?;
504        }
505    }
506
507    // mzML stores RT in minutes by convention.
508    let rt_min = rec.retention_time_sec / 60.0;
509    writeln!(
510        out,
511        r#"            <cvParam cvRef="MS" accession="MS:1000016" name="scan start time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000031" unitName="minute"/>"#,
512        rt_min
513    )?;
514
515    if let Some(it) = rec.ion_injection_time_ms {
516        writeln!(
517            out,
518            r#"            <cvParam cvRef="MS" accession="MS:1000927" name="ion injection time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000028" unitName="millisecond"/>"#,
519            it
520        )?;
521    }
522
523    if let Some(mob) = rec.inv_mobility {
524        writeln!(
525            out,
526            r#"            <cvParam cvRef="MS" accession="MS:1002815" name="inverse reduced ion mobility" value="{:.6}" unitCvRef="MS" unitAccession="MS:1002814" unitName="volt-second per square centimeter"/>"#,
527            mob
528        )?;
529    }
530
531    writeln!(out, r#"            <scanWindowList count="1">"#)?;
532    writeln!(out, r#"              <scanWindow>"#)?;
533    writeln!(
534        out,
535        r#"                <cvParam cvRef="MS" accession="MS:1000501" name="scan window lower limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
536        lo_mz
537    )?;
538    writeln!(
539        out,
540        r#"                <cvParam cvRef="MS" accession="MS:1000500" name="scan window upper limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
541        hi_mz
542    )?;
543    writeln!(out, r#"              </scanWindow>"#)?;
544    writeln!(out, r#"            </scanWindowList>"#)?;
545    writeln!(out, r#"          </scan>"#)?;
546    writeln!(out, r#"        </scanList>"#)?;
547
548    if let Some(pre) = rec.precursor.as_ref() {
549        writeln!(out, r#"        <precursorList count="1">"#)?;
550        if let Some(ref nid) = pre.precursor_native_id {
551            writeln!(
552                out,
553                r#"          <precursor spectrumRef="{}">"#,
554                escape(nid)
555            )?;
556        } else {
557            writeln!(out, r#"          <precursor>"#)?;
558        }
559
560        if pre.target_mz.is_some() || pre.isolation_width.is_some() {
561            writeln!(out, r#"            <isolationWindow>"#)?;
562            if let Some(mz) = pre.target_mz {
563                writeln!(
564                    out,
565                    r#"              <cvParam cvRef="MS" accession="MS:1000827" name="isolation window target m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
566                    mz
567                )?;
568            }
569            if let Some(w) = pre.isolation_width {
570                let half = w / 2.0;
571                writeln!(
572                    out,
573                    r#"              <cvParam cvRef="MS" accession="MS:1000828" name="isolation window lower offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
574                    half
575                )?;
576                writeln!(
577                    out,
578                    r#"              <cvParam cvRef="MS" accession="MS:1000829" name="isolation window upper offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
579                    half
580                )?;
581            }
582            writeln!(out, r#"            </isolationWindow>"#)?;
583        }
584
585        if let Some(mz) = pre.selected_mz {
586            writeln!(out, r#"            <selectedIonList count="1">"#)?;
587            writeln!(out, r#"              <selectedIon>"#)?;
588            writeln!(
589                out,
590                r#"                <cvParam cvRef="MS" accession="MS:1000744" name="selected ion m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
591                mz
592            )?;
593            if let Some(z) = pre.charge {
594                writeln!(
595                    out,
596                    r#"                <cvParam cvRef="MS" accession="MS:1000041" name="charge state" value="{z}"/>"#
597                )?;
598            }
599            if let Some(i) = pre.intensity {
600                writeln!(
601                    out,
602                    r#"                <cvParam cvRef="MS" accession="MS:1000042" name="peak intensity" value="{:.6}"/>"#,
603                    i
604                )?;
605            }
606            writeln!(out, r#"              </selectedIon>"#)?;
607            writeln!(out, r#"            </selectedIonList>"#)?;
608        }
609
610        writeln!(out, r#"            <activation>"#)?;
611        if let Some(act) = pre.activation {
612            let (acc, name) = activation_cv(act, pre.analyzer);
613            writeln!(
614                out,
615                r#"              <cvParam cvRef="MS" accession="{acc}" name="{name}" value=""/>"#
616            )?;
617        } else {
618            writeln!(
619                out,
620                r#"              <cvParam cvRef="MS" accession="MS:1000133" name="collision-induced dissociation" value=""/>"#
621            )?;
622        }
623        if let Some(e) = pre.collision_energy {
624            if pre.ce_is_nce {
625                writeln!(
626                    out,
627                    r#"              <cvParam cvRef="MS" accession="MS:1002013" name="normalized collision energy" value="{:.2}"/>"#,
628                    e
629                )?;
630            } else {
631                writeln!(
632                    out,
633                    r#"              <cvParam cvRef="MS" accession="MS:1000045" name="collision energy" value="{:.2}" unitCvRef="UO" unitAccession="UO:0000266" unitName="electronvolt"/>"#,
634                    e
635                )?;
636            }
637        }
638        writeln!(out, r#"            </activation>"#)?;
639        writeln!(out, r#"          </precursor>"#)?;
640        writeln!(out, r#"        </precursorList>"#)?;
641    }
642
643    if n_peaks > 0 {
644        let mz_b64 = encode_f64_array(&rec.mz);
645        let int_b64 = encode_f32_array(&rec.intensity);
646        let mobility_b64_opt = rec
647            .inv_mobility_per_peak
648            .as_ref()
649            .filter(|v| v.len() == n_peaks)
650            .map(|v| encode_f32_array(v));
651        let array_count = 2 + usize::from(mobility_b64_opt.is_some());
652
653        writeln!(
654            out,
655            r#"        <binaryDataArrayList count="{array_count}">"#
656        )?;
657
658        writeln!(
659            out,
660            r#"          <binaryDataArray encodedLength="{}">"#,
661            mz_b64.len()
662        )?;
663        writeln!(
664            out,
665            r#"            <cvParam cvRef="MS" accession="MS:1000514" name="m/z array" value=""/>"#
666        )?;
667        writeln!(
668            out,
669            r#"            <cvParam cvRef="MS" accession="MS:1000523" name="64-bit float" value=""/>"#
670        )?;
671        writeln!(
672            out,
673            r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
674        )?;
675        writeln!(out, r#"            <binary>{mz_b64}</binary>"#)?;
676        writeln!(out, r#"          </binaryDataArray>"#)?;
677
678        writeln!(
679            out,
680            r#"          <binaryDataArray encodedLength="{}">"#,
681            int_b64.len()
682        )?;
683        writeln!(
684            out,
685            r#"            <cvParam cvRef="MS" accession="MS:1000515" name="intensity array" value=""/>"#
686        )?;
687        writeln!(
688            out,
689            r#"            <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
690        )?;
691        writeln!(
692            out,
693            r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
694        )?;
695        writeln!(out, r#"            <binary>{int_b64}</binary>"#)?;
696        writeln!(out, r#"          </binaryDataArray>"#)?;
697
698        if let Some(mobility_b64) = mobility_b64_opt {
699            let (cv_acc, cv_name, unit_acc, unit_ref, unit_name) = match mobility_kind {
700                Some(MobilityArrayKind::DriftTimeMilliseconds) => (
701                    "MS:1003007",
702                    "raw ion mobility array",
703                    "UO:0000028",
704                    "UO",
705                    "millisecond",
706                ),
707                Some(MobilityArrayKind::InverseReducedVsPerCm2) | None => (
708                    "MS:1003008",
709                    "raw inverse reduced ion mobility array",
710                    "MS:1002814",
711                    "MS",
712                    "volt-second per square centimeter",
713                ),
714            };
715            writeln!(
716                out,
717                r#"          <binaryDataArray encodedLength="{}">"#,
718                mobility_b64.len()
719            )?;
720            writeln!(
721                out,
722                r#"            <cvParam cvRef="MS" accession="{cv_acc}" name="{cv_name}" value="" unitCvRef="{unit_ref}" unitAccession="{unit_acc}" unitName="{unit_name}"/>"#
723            )?;
724            writeln!(
725                out,
726                r#"            <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
727            )?;
728            writeln!(
729                out,
730                r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
731            )?;
732            writeln!(out, r#"            <binary>{mobility_b64}</binary>"#)?;
733            writeln!(out, r#"          </binaryDataArray>"#)?;
734        }
735
736        writeln!(out, r#"        </binaryDataArrayList>"#)?;
737    }
738
739    writeln!(out, r#"      </spectrum>"#)?;
740    Ok(())
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    #[test]
748    fn base64_rfc_vectors() {
749        assert_eq!(base64_encode(b""), "");
750        assert_eq!(base64_encode(b"f"), "Zg==");
751        assert_eq!(base64_encode(b"fo"), "Zm8=");
752        assert_eq!(base64_encode(b"foo"), "Zm9v");
753        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
754        assert_eq!(base64_encode(b"Man"), "TWFu");
755    }
756}