Skip to main content

openmassspec_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        // Non-indexed documents have a plain <mzML> root, so they reference
309        // the base schema (matching version="1.1.0"), not the indexed
310        // wrapper schema used for <indexedmzML> output.
311        writeln!(
312            out,
313            r#"      xsi:schemaLocation="http://psi.hupo.org/ms/mzml http://psidev.info/files/ms/mzML/xsd/mzML1.1.0.xsd""#
314        )?;
315        writeln!(out, r#"      version="1.1.0">"#)?;
316    }
317
318    writeln!(out, r#"  <cvList count="2">"#)?;
319    writeln!(
320        out,
321        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"/>"#
322    )?;
323    writeln!(
324        out,
325        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"/>"#
326    )?;
327    writeln!(out, r#"  </cvList>"#)?;
328
329    writeln!(out, r#"  <fileDescription>"#)?;
330    writeln!(out, r#"    <fileContent>"#)?;
331    writeln!(
332        out,
333        r#"      <cvParam cvRef="MS" accession="MS:1000579" name="MS1 spectrum" value=""/>"#
334    )?;
335    writeln!(
336        out,
337        r#"      <cvParam cvRef="MS" accession="MS:1000580" name="MSn spectrum" value=""/>"#
338    )?;
339    writeln!(out, r#"    </fileContent>"#)?;
340    writeln!(out, r#"    <sourceFileList count="1">"#)?;
341    writeln!(
342        out,
343        r#"      <sourceFile id="sf1" name="{}" location="">"#,
344        escape(&meta.source_file_name)
345    )?;
346    write_cv(out, "        ", &meta.source_file_format)?;
347    write_cv(out, "        ", &meta.native_id_format)?;
348    writeln!(out, r#"      </sourceFile>"#)?;
349    writeln!(out, r#"    </sourceFileList>"#)?;
350    writeln!(out, r#"  </fileDescription>"#)?;
351
352    writeln!(out, r#"  <softwareList count="1">"#)?;
353    writeln!(
354        out,
355        r#"    <software id="{}" version="{}">"#,
356        escape(&meta.software_name),
357        escape(&meta.software_version)
358    )?;
359    writeln!(
360        out,
361        r#"      <cvParam cvRef="MS" accession="MS:1000799" name="custom unreleased software tool" value="{}"/>"#,
362        escape(&meta.software_name)
363    )?;
364    writeln!(out, r#"    </software>"#)?;
365    writeln!(out, r#"  </softwareList>"#)?;
366
367    writeln!(out, r#"  <instrumentConfigurationList count="1">"#)?;
368    writeln!(out, r#"    <instrumentConfiguration id="IC1">"#)?;
369    write_cv(out, "      ", &meta.instrument)?;
370    writeln!(out, r#"    </instrumentConfiguration>"#)?;
371    writeln!(out, r#"  </instrumentConfigurationList>"#)?;
372
373    writeln!(out, r#"  <dataProcessingList count="1">"#)?;
374    writeln!(out, r#"    <dataProcessing id="dp1">"#)?;
375    writeln!(
376        out,
377        r#"      <processingMethod order="0" softwareRef="{}">"#,
378        escape(&meta.software_name)
379    )?;
380    writeln!(
381        out,
382        r#"        <cvParam cvRef="MS" accession="MS:1000544" name="Conversion to mzML" value=""/>"#
383    )?;
384    writeln!(out, r#"      </processingMethod>"#)?;
385    writeln!(out, r#"    </dataProcessing>"#)?;
386    writeln!(out, r#"  </dataProcessingList>"#)?;
387
388    writeln!(
389        out,
390        r#"  <run id="{}" defaultInstrumentConfigurationRef="IC1" defaultSourceFileRef="sf1">"#,
391        escape(&meta.source_file_name)
392    )?;
393    writeln!(
394        out,
395        r#"    <spectrumList count="{}" defaultDataProcessingRef="dp1">"#,
396        n_spectra
397    )?;
398    Ok(())
399}
400
401fn write_cv<W: Write>(out: &mut W, indent: &str, cv: &CvTerm) -> Result<()> {
402    writeln!(
403        out,
404        r#"{indent}<cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
405        cv.accession,
406        escape(&cv.name)
407    )
408}
409
410fn write_spectrum<W: Write>(
411    out: &mut W,
412    rec: &SpectrumRecord,
413    mobility_kind: Option<MobilityArrayKind>,
414) -> Result<()> {
415    let spectrum_type = if rec.ms_level <= 1 {
416        ("MS:1000579", "MS1 spectrum")
417    } else {
418        ("MS:1000580", "MSn spectrum")
419    };
420    let n_peaks = rec.mz.len();
421
422    writeln!(
423        out,
424        r#"      <spectrum id="{id}" index="{idx}" defaultArrayLength="{n}">"#,
425        id = escape(&rec.native_id),
426        idx = rec.index,
427        n = n_peaks
428    )?;
429    writeln!(
430        out,
431        r#"        <cvParam cvRef="MS" accession="MS:1000511" name="ms level" value="{}"/>"#,
432        rec.ms_level
433    )?;
434    writeln!(
435        out,
436        r#"        <cvParam cvRef="MS" accession="{}" name="{}" value=""/>"#,
437        spectrum_type.0, spectrum_type.1
438    )?;
439
440    match rec.scan_mode {
441        Some(ScanMode::Centroid) => writeln!(
442            out,
443            r#"        <cvParam cvRef="MS" accession="MS:1000127" name="centroid spectrum" value=""/>"#
444        )?,
445        _ => writeln!(
446            out,
447            r#"        <cvParam cvRef="MS" accession="MS:1000128" name="profile spectrum" value=""/>"#
448        )?,
449    }
450
451    match rec.polarity {
452        Some(Polarity::Positive) => writeln!(
453            out,
454            r#"        <cvParam cvRef="MS" accession="MS:1000130" name="positive scan" value=""/>"#
455        )?,
456        Some(Polarity::Negative) => writeln!(
457            out,
458            r#"        <cvParam cvRef="MS" accession="MS:1000129" name="negative scan" value=""/>"#
459        )?,
460        None => {}
461    }
462
463    let tic = rec.effective_tic();
464    let (bp_mz, bp_int) = rec.effective_base_peak().unwrap_or((0.0, 0.0));
465    let (lo_mz, hi_mz) = rec.effective_mz_range().unwrap_or((0.0, 0.0));
466
467    writeln!(
468        out,
469        r#"        <cvParam cvRef="MS" accession="MS:1000285" name="total ion current" value="{:.6}"/>"#,
470        tic
471    )?;
472    writeln!(
473        out,
474        r#"        <cvParam cvRef="MS" accession="MS:1000504" name="base peak m/z" value="{:.6}"/>"#,
475        bp_mz
476    )?;
477    writeln!(
478        out,
479        r#"        <cvParam cvRef="MS" accession="MS:1000505" name="base peak intensity" value="{:.6}"/>"#,
480        bp_int
481    )?;
482    writeln!(
483        out,
484        r#"        <cvParam cvRef="MS" accession="MS:1000528" name="lowest observed m/z" value="{:.6}"/>"#,
485        lo_mz
486    )?;
487    writeln!(
488        out,
489        r#"        <cvParam cvRef="MS" accession="MS:1000527" name="highest observed m/z" value="{:.6}"/>"#,
490        hi_mz
491    )?;
492
493    writeln!(out, r#"        <scanList count="1">"#)?;
494    writeln!(
495        out,
496        r#"          <cvParam cvRef="MS" accession="MS:1000795" name="no combination" value=""/>"#
497    )?;
498    writeln!(out, r#"          <scan>"#)?;
499
500    if let Some(f) = rec.filter.as_deref() {
501        if !f.is_empty() {
502            writeln!(
503                out,
504                r#"            <cvParam cvRef="MS" accession="MS:1000512" name="filter string" value="{}"/>"#,
505                escape(f)
506            )?;
507        }
508    }
509
510    // mzML stores RT in minutes by convention.
511    let rt_min = rec.retention_time_sec / 60.0;
512    writeln!(
513        out,
514        r#"            <cvParam cvRef="MS" accession="MS:1000016" name="scan start time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000031" unitName="minute"/>"#,
515        rt_min
516    )?;
517
518    if let Some(it) = rec.ion_injection_time_ms {
519        writeln!(
520            out,
521            r#"            <cvParam cvRef="MS" accession="MS:1000927" name="ion injection time" value="{:.6}" unitCvRef="UO" unitAccession="UO:0000028" unitName="millisecond"/>"#,
522            it
523        )?;
524    }
525
526    if let Some(mob) = rec.inv_mobility {
527        writeln!(
528            out,
529            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"/>"#,
530            mob
531        )?;
532    }
533
534    writeln!(out, r#"            <scanWindowList count="1">"#)?;
535    writeln!(out, r#"              <scanWindow>"#)?;
536    writeln!(
537        out,
538        r#"                <cvParam cvRef="MS" accession="MS:1000501" name="scan window lower limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
539        lo_mz
540    )?;
541    writeln!(
542        out,
543        r#"                <cvParam cvRef="MS" accession="MS:1000500" name="scan window upper limit" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
544        hi_mz
545    )?;
546    writeln!(out, r#"              </scanWindow>"#)?;
547    writeln!(out, r#"            </scanWindowList>"#)?;
548    writeln!(out, r#"          </scan>"#)?;
549    writeln!(out, r#"        </scanList>"#)?;
550
551    if let Some(pre) = rec.precursor.as_ref() {
552        writeln!(out, r#"        <precursorList count="1">"#)?;
553        if let Some(ref nid) = pre.precursor_native_id {
554            writeln!(
555                out,
556                r#"          <precursor spectrumRef="{}">"#,
557                escape(nid)
558            )?;
559        } else {
560            writeln!(out, r#"          <precursor>"#)?;
561        }
562
563        if pre.target_mz.is_some() || pre.isolation_width.is_some() {
564            writeln!(out, r#"            <isolationWindow>"#)?;
565            if let Some(mz) = pre.target_mz {
566                writeln!(
567                    out,
568                    r#"              <cvParam cvRef="MS" accession="MS:1000827" name="isolation window target m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
569                    mz
570                )?;
571            }
572            if let Some(w) = pre.isolation_width {
573                let half = w / 2.0;
574                writeln!(
575                    out,
576                    r#"              <cvParam cvRef="MS" accession="MS:1000828" name="isolation window lower offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
577                    half
578                )?;
579                writeln!(
580                    out,
581                    r#"              <cvParam cvRef="MS" accession="MS:1000829" name="isolation window upper offset" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
582                    half
583                )?;
584            }
585            writeln!(out, r#"            </isolationWindow>"#)?;
586        }
587
588        if let Some(mz) = pre.selected_mz {
589            writeln!(out, r#"            <selectedIonList count="1">"#)?;
590            writeln!(out, r#"              <selectedIon>"#)?;
591            writeln!(
592                out,
593                r#"                <cvParam cvRef="MS" accession="MS:1000744" name="selected ion m/z" value="{:.6}" unitCvRef="MS" unitAccession="MS:1000040" unitName="m/z"/>"#,
594                mz
595            )?;
596            if let Some(z) = pre.charge {
597                writeln!(
598                    out,
599                    r#"                <cvParam cvRef="MS" accession="MS:1000041" name="charge state" value="{z}"/>"#
600                )?;
601            }
602            if let Some(i) = pre.intensity {
603                writeln!(
604                    out,
605                    r#"                <cvParam cvRef="MS" accession="MS:1000042" name="peak intensity" value="{:.6}"/>"#,
606                    i
607                )?;
608            }
609            writeln!(out, r#"              </selectedIon>"#)?;
610            writeln!(out, r#"            </selectedIonList>"#)?;
611        }
612
613        writeln!(out, r#"            <activation>"#)?;
614        if let Some(act) = pre.activation {
615            let (acc, name) = activation_cv(act, pre.analyzer);
616            writeln!(
617                out,
618                r#"              <cvParam cvRef="MS" accession="{acc}" name="{name}" value=""/>"#
619            )?;
620        } else {
621            writeln!(
622                out,
623                r#"              <cvParam cvRef="MS" accession="MS:1000133" name="collision-induced dissociation" value=""/>"#
624            )?;
625        }
626        if let Some(e) = pre.collision_energy {
627            if pre.ce_is_nce {
628                writeln!(
629                    out,
630                    r#"              <cvParam cvRef="MS" accession="MS:1002013" name="normalized collision energy" value="{:.2}"/>"#,
631                    e
632                )?;
633            } else {
634                writeln!(
635                    out,
636                    r#"              <cvParam cvRef="MS" accession="MS:1000045" name="collision energy" value="{:.2}" unitCvRef="UO" unitAccession="UO:0000266" unitName="electronvolt"/>"#,
637                    e
638                )?;
639            }
640        }
641        writeln!(out, r#"            </activation>"#)?;
642        writeln!(out, r#"          </precursor>"#)?;
643        writeln!(out, r#"        </precursorList>"#)?;
644    }
645
646    if n_peaks > 0 {
647        let mz_b64 = encode_f64_array(&rec.mz);
648        let int_b64 = encode_f32_array(&rec.intensity);
649        let mobility_b64_opt = rec
650            .inv_mobility_per_peak
651            .as_ref()
652            .filter(|v| v.len() == n_peaks)
653            .map(|v| encode_f32_array(v));
654        let array_count = 2 + usize::from(mobility_b64_opt.is_some());
655
656        writeln!(
657            out,
658            r#"        <binaryDataArrayList count="{array_count}">"#
659        )?;
660
661        writeln!(
662            out,
663            r#"          <binaryDataArray encodedLength="{}">"#,
664            mz_b64.len()
665        )?;
666        writeln!(
667            out,
668            r#"            <cvParam cvRef="MS" accession="MS:1000514" name="m/z array" value=""/>"#
669        )?;
670        writeln!(
671            out,
672            r#"            <cvParam cvRef="MS" accession="MS:1000523" name="64-bit float" value=""/>"#
673        )?;
674        writeln!(
675            out,
676            r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
677        )?;
678        writeln!(out, r#"            <binary>{mz_b64}</binary>"#)?;
679        writeln!(out, r#"          </binaryDataArray>"#)?;
680
681        writeln!(
682            out,
683            r#"          <binaryDataArray encodedLength="{}">"#,
684            int_b64.len()
685        )?;
686        writeln!(
687            out,
688            r#"            <cvParam cvRef="MS" accession="MS:1000515" name="intensity array" value=""/>"#
689        )?;
690        writeln!(
691            out,
692            r#"            <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
693        )?;
694        writeln!(
695            out,
696            r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
697        )?;
698        writeln!(out, r#"            <binary>{int_b64}</binary>"#)?;
699        writeln!(out, r#"          </binaryDataArray>"#)?;
700
701        if let Some(mobility_b64) = mobility_b64_opt {
702            let (cv_acc, cv_name, unit_acc, unit_ref, unit_name) = match mobility_kind {
703                Some(MobilityArrayKind::DriftTimeMilliseconds) => (
704                    "MS:1003007",
705                    "raw ion mobility array",
706                    "UO:0000028",
707                    "UO",
708                    "millisecond",
709                ),
710                Some(MobilityArrayKind::InverseReducedVsPerCm2) | None => (
711                    "MS:1003008",
712                    "raw inverse reduced ion mobility array",
713                    "MS:1002814",
714                    "MS",
715                    "volt-second per square centimeter",
716                ),
717            };
718            writeln!(
719                out,
720                r#"          <binaryDataArray encodedLength="{}">"#,
721                mobility_b64.len()
722            )?;
723            writeln!(
724                out,
725                r#"            <cvParam cvRef="MS" accession="{cv_acc}" name="{cv_name}" value="" unitCvRef="{unit_ref}" unitAccession="{unit_acc}" unitName="{unit_name}"/>"#
726            )?;
727            writeln!(
728                out,
729                r#"            <cvParam cvRef="MS" accession="MS:1000521" name="32-bit float" value=""/>"#
730            )?;
731            writeln!(
732                out,
733                r#"            <cvParam cvRef="MS" accession="MS:1000576" name="no compression" value=""/>"#
734            )?;
735            writeln!(out, r#"            <binary>{mobility_b64}</binary>"#)?;
736            writeln!(out, r#"          </binaryDataArray>"#)?;
737        }
738
739        writeln!(out, r#"        </binaryDataArrayList>"#)?;
740    }
741
742    writeln!(out, r#"      </spectrum>"#)?;
743    Ok(())
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn base64_rfc_vectors() {
752        assert_eq!(base64_encode(b""), "");
753        assert_eq!(base64_encode(b"f"), "Zg==");
754        assert_eq!(base64_encode(b"fo"), "Zm8=");
755        assert_eq!(base64_encode(b"foo"), "Zm9v");
756        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
757        assert_eq!(base64_encode(b"Man"), "TWFu");
758    }
759}