Skip to main content

mrc/header/
mod.rs

1//! MRC-2014 header structure and builder.
2//!
3//! The [`Header`] struct mirrors the 1024-byte fixed header defined by the
4//! MRC-2014 specification. Every field is a typed public member — dimensions,
5//! cell parameters, axis mapping, density statistics, text labels, and more.
6//!
7//! The `Header` provides encode/decode methods for raw bytes, validation
8//! helpers at three levels (basic, detailed, permissive), and convenience
9//! accessors for common metadata (voxel size, cell parameters, volume type,
10//! labels, FEI extended header info).
11//!
12//! Use [`HeaderBuilder`] to construct new headers with a fluent API that
13//! validates on build.
14//!
15//! # Example — decode/encode round-trip
16//!
17//! ```
18//! use mrc::Header;
19//!
20//! let mut raw = [0u8; 1024];
21//! // Standard MRC-2014 markers
22//! raw[208..212].copy_from_slice(b"MAP ");
23//! // Little-endian MACHST
24//! raw[212..216].copy_from_slice(&[0x44, 0x44, 0x00, 0x00]);
25//! // Dimensions: 64 x 64 x 1
26//! raw[0..4].copy_from_slice(&(64i32).to_le_bytes());
27//! raw[4..8].copy_from_slice(&(64i32).to_le_bytes());
28//! raw[8..12].copy_from_slice(&(1i32).to_le_bytes());
29//! // Mode 2 (Float32)
30//! raw[12..16].copy_from_slice(&(2i32).to_le_bytes());
31//!
32//! let header = Header::decode_from_bytes(&raw);
33//! assert_eq!(header.nx, 64);
34//! assert_eq!(header.ny, 64);
35//! assert_eq!(header.nz, 1);
36//! assert_eq!(header.mode, 2);
37//!
38//! let mut encoded = [0u8; 1024];
39//! header.encode_to_bytes(&mut encoded);
40//! assert_eq!(raw, encoded);
41//! ```
42
43pub mod agar;
44pub mod ccp4;
45pub mod fei;
46pub mod mrco;
47pub mod seri;
48
49pub use agar::{AGAR_RECORD_SIZE, AgarRecord, parse_agar_records};
50pub use ccp4::{CCP4_RECORD_SIZE, Ccp4Record, parse_ccp4_records};
51pub use fei::{
52    FEI1_RECORD_SIZE, FEI2_RECORD_SIZE, Fei1Metadata, Fei2Metadata, parse_fei1_records,
53    parse_fei2_records,
54};
55pub use mrco::{MRCO_RECORD_SIZE, MrcoRecord, parse_mrco_records};
56pub use seri::{SERI_RECORD_SIZE, SeriRecord, parse_seri_records};
57
58use crate::Mode;
59
60// ============================================================================
61// Macro: generate parse_*_records() for fixed-size extended header types.
62// The `from_bytes` method on the record type must exist and return Option.
63// ============================================================================
64#[doc(hidden)]
65#[macro_export]
66macro_rules! impl_record_parser {
67    ($record:ident, $size:ident, $parse_fn:ident) => {
68        /// Parse extended header bytes as typed records.
69        ///
70        /// Returns `None` if `bytes` is empty or if its length is not a
71        /// multiple of the record size.
72        pub fn $parse_fn(bytes: &[u8]) -> Option<Vec<$record>> {
73            if bytes.is_empty() || bytes.len() % $size != 0 {
74                return None;
75            }
76            let count = bytes.len() / $size;
77            let mut records = Vec::with_capacity(count);
78            for i in 0..count {
79                let start = i * $size;
80                records.push($record::from_bytes(&bytes[start..start + $size])?);
81            }
82            Some(records)
83        }
84    };
85}
86
87#[cfg(feature = "serde")]
88use serde::{Deserialize, Serialize};
89
90/// Known extended header types identified by the 4-byte EXTTYP field.
91///
92/// This enum maps the `exttyp` identifier stored in `extra[8..12]` of the
93/// MRC-2014 header to a Rust type for dispatch.  Unknown identifiers are
94/// captured as [`Unknown`](ExtHeaderType::Unknown) with the raw bytes.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
97#[non_exhaustive]
98pub enum ExtHeaderType {
99    /// CCP4 symmetry records (`"CCP4"`).
100    Ccp4,
101    /// Legacy MRCO format records (`"MRCO"`).
102    Mrco,
103    /// SerialEM tilt-series records (`"SERI"`).
104    Seri,
105    /// Agard microscope records (`"AGAR"`).
106    Agar,
107    /// FEI/Thermo Fisher Type 1 metadata (`"FEI1"`).
108    Fei1,
109    /// FEI/Thermo Fisher Type 2 metadata (`"FEI2"`).
110    Fei2,
111    /// HDF5-based extended header (`"HDF5"`).
112    Hdf5,
113    /// Any unrecognized extended header type.
114    Unknown([u8; 4]),
115}
116
117impl ExtHeaderType {
118    /// Detect the extended header type from a 4-byte EXTTYP identifier.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use mrc::ExtHeaderType;
124    /// let typ = ExtHeaderType::from_exttyp(*b"CCP4");
125    /// assert_eq!(typ, ExtHeaderType::Ccp4);
126    /// ```
127    pub fn from_exttyp(exttyp: [u8; 4]) -> Self {
128        match &exttyp {
129            b"CCP4" => Self::Ccp4,
130            b"MRCO" => Self::Mrco,
131            b"SERI" => Self::Seri,
132            b"AGAR" => Self::Agar,
133            b"FEI1" => Self::Fei1,
134            b"FEI2" => Self::Fei2,
135            b"HDF5" => Self::Hdf5,
136            _ => Self::Unknown(exttyp),
137        }
138    }
139
140    /// Detect the extended header type from a [`Header`].
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use mrc::{Header, ExtHeaderType};
146    /// let mut h = Header::new();
147    /// h.set_exttyp(*b"FEI1");
148    /// let typ = ExtHeaderType::from_header(&h);
149    /// assert_eq!(typ, ExtHeaderType::Fei1);
150    /// ```
151    #[inline]
152    pub fn from_header(header: &Header) -> Self {
153        Self::from_exttyp(header.exttyp())
154    }
155}
156
157/// Parsed extended header data, dispatched by [`ExtHeaderType`].
158///
159/// Returned by [`Reader::parse_extended_header`](crate::Reader::parse_extended_header).
160/// Each variant wraps the fully-parsed records for that extended header type.
161#[derive(Debug, Clone, PartialEq)]
162#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
163#[non_exhaustive]
164pub enum ExtHeaderData {
165    /// CCP4 symmetry records.
166    Ccp4(Vec<Ccp4Record>),
167    /// Legacy MRCO format records.
168    Mrco(Vec<MrcoRecord>),
169    /// SerialEM tilt-series records.
170    Seri(Vec<SeriRecord>),
171    /// Agard microscope records.
172    Agar(Vec<AgarRecord>),
173    /// FEI/Thermo Fisher Type 1 metadata records.
174    Fei1(Vec<Fei1Metadata>),
175    /// FEI/Thermo Fisher Type 2 metadata records.
176    Fei2(Vec<Fei2Metadata>),
177    /// No extended header data (nsymbt == 0) or unrecognized type.
178    None,
179}
180
181impl ExtHeaderData {
182    /// Parse extended header bytes according to the given [`ExtHeaderType`].
183    ///
184    /// Returns [`None`](ExtHeaderData::None) when `bytes` is empty or the
185    /// extended header type is unknown.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use mrc::{ExtHeaderType, ExtHeaderData};
191    /// let data = ExtHeaderData::parse(ExtHeaderType::Ccp4, &[]);
192    /// assert_eq!(data, ExtHeaderData::None);
193    /// ```
194    pub fn parse(ext_type: ExtHeaderType, bytes: &[u8]) -> Self {
195        if bytes.is_empty() {
196            return Self::None;
197        }
198        match ext_type {
199            ExtHeaderType::Ccp4 => parse_ccp4_records(bytes)
200                .map(Self::Ccp4)
201                .unwrap_or(Self::None),
202            ExtHeaderType::Mrco => parse_mrco_records(bytes)
203                .map(Self::Mrco)
204                .unwrap_or(Self::None),
205            ExtHeaderType::Seri => parse_seri_records(bytes)
206                .map(Self::Seri)
207                .unwrap_or(Self::None),
208            ExtHeaderType::Agar => parse_agar_records(bytes)
209                .map(Self::Agar)
210                .unwrap_or(Self::None),
211            ExtHeaderType::Fei1 => parse_fei1_records(bytes)
212                .map(Self::Fei1)
213                .unwrap_or(Self::None),
214            ExtHeaderType::Fei2 => parse_fei2_records(bytes)
215                .map(Self::Fei2)
216                .unwrap_or(Self::None),
217            ExtHeaderType::Hdf5 | ExtHeaderType::Unknown(_) => Self::None,
218        }
219    }
220
221    /// Parse using the [`ExtHeaderType`] detected from a [`Header`].
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use mrc::{Header, ExtHeaderData};
227    /// let h = Header::new();
228    /// let data = ExtHeaderData::from_header(&h, &[]);
229    /// assert_eq!(data, ExtHeaderData::None);
230    /// ```
231    #[inline]
232    pub fn from_header(header: &Header, bytes: &[u8]) -> Self {
233        Self::parse(ExtHeaderType::from_header(header), bytes)
234    }
235}
236
237// Header field offsets (MRC2014 format)
238const OFFSET_NX: usize = 0;
239const OFFSET_NY: usize = 4;
240const OFFSET_NZ: usize = 8;
241const OFFSET_MODE: usize = 12;
242const OFFSET_NXSTART: usize = 16;
243const OFFSET_NYSTART: usize = 20;
244const OFFSET_NZSTART: usize = 24;
245const OFFSET_MX: usize = 28;
246const OFFSET_MY: usize = 32;
247const OFFSET_MZ: usize = 36;
248const OFFSET_XLEN: usize = 40;
249const OFFSET_YLEN: usize = 44;
250const OFFSET_ZLEN: usize = 48;
251const OFFSET_ALPHA: usize = 52;
252const OFFSET_BETA: usize = 56;
253const OFFSET_GAMMA: usize = 60;
254const OFFSET_MAPC: usize = 64;
255const OFFSET_MAPR: usize = 68;
256const OFFSET_MAPS: usize = 72;
257const OFFSET_DMIN: usize = 76;
258const OFFSET_DMAX: usize = 80;
259const OFFSET_DMEAN: usize = 84;
260const OFFSET_ISPG: usize = 88;
261const OFFSET_NSYMBT: usize = 92;
262const OFFSET_EXTRA: usize = 96;
263const OFFSET_EXTTYP: usize = 104; // extra[8..12]
264const OFFSET_NVERSION: usize = 108; // extra[12..16]
265const OFFSET_ORIGIN: usize = 196;
266const OFFSET_MAP: usize = 208;
267const OFFSET_MACHST: usize = 212;
268const OFFSET_RMS: usize = 216;
269const OFFSET_NLABL: usize = 220;
270const OFFSET_LABEL: usize = 224;
271
272/// Default `extra` bytes with NVERSION=20141 encoded in little-endian.
273const DEFAULT_EXTRA: [u8; 100] = {
274    let mut e = [0u8; 100];
275    // NVERSION = 20141 (latest MRC2014 update), stored little-endian in extra[12..16]
276    e[12] = 0xAD;
277    e[13] = 0x4E;
278    e[14] = 0x00;
279    e[15] = 0x00;
280    e
281};
282/// Mirror of the 1024-byte MRC-2014 fixed header.
283///
284/// Every field is a typed public member — dimensions, cell parameters,
285/// axis mapping, density statistics, text labels, and more.
286///
287/// Construct via [`Header::new()`] or [`HeaderBuilder`], decode from raw
288/// bytes via [`Header::decode_from_bytes`], and encode via
289/// [`Header::encode_to_bytes`].
290///
291/// # Official MRC2014 specification: <https://www.ccpem.ac.uk/mrc-format/mrc2014/>
292#[repr(C, align(4))]
293#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
294#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct Header {
296    /// Number of columns in 3D data array (fast axis)
297    pub nx: i32,
298    /// Number of rows in 3D data array (medium axis)
299    pub ny: i32,
300    /// Number of sections in 3D data array (slow axis)
301    pub nz: i32,
302    /// Mode value (see `Mode` enum)
303    pub mode: i32,
304    /// Location of first column in unit cell
305    pub nxstart: i32,
306    /// Location of first row in unit cell
307    pub nystart: i32,
308    /// Location of first section in unit cell
309    pub nzstart: i32,
310    /// Sampling along X axis of unit cell
311    pub mx: i32,
312    /// Sampling along Y axis of unit cell
313    pub my: i32,
314    /// Sampling along Z axis of unit cell
315    pub mz: i32,
316    /// CELLA: Cell dimensions (unit cell edge length) in angstroms (Å) along X axis
317    pub xlen: f32,
318    /// CELLA: Cell dimensions (unit cell edge length) in angstroms (Å) along Y axis
319    pub ylen: f32,
320    /// CELLA: Cell dimensions (unit cell edge length) in angstroms (Å) along Z axis
321    pub zlen: f32,
322    /// CELLB: Cell angles in degrees between the crystallographic axes Y and Z axes
323    pub alpha: f32,
324    /// CELLB: Cell angles in degrees between the crystallographic axes X and Z axes
325    pub beta: f32,
326    /// CELLB: Cell angles in degrees between the crystallographic axes X and Y axes
327    pub gamma: f32,
328    /// One-based index of column axis (1, 2, 3 for X, Y, Z)
329    pub mapc: i32,
330    /// One-based index of row axis (1, 2, 3 for X, Y, Z)
331    pub mapr: i32,
332    /// One-based index of section axis (1, 2, 3 for X, Y, Z)
333    pub maps: i32,
334    /// Minimum density value
335    pub dmin: f32,
336    /// Maximum density value
337    pub dmax: f32,
338    /// Mean density value
339    pub dmean: f32,
340    /// Space group number; 0 implies 2D image or image stack.
341    ///
342    /// For crystallography, represents the actual space group.
343    /// For volume stacks, conventionally ISPG = space group number + 400.
344    pub ispg: i32,
345    /// Size of extended header (which follows main header) in bytes.
346    /// May contain symmetry records or other metadata (indicated by EXTTYP).
347    pub nsymbt: i32,
348    /// Extra space used for anything.
349    /// Bytes 8–11 hold EXTTYP, 12–15 NVERSION.
350    #[cfg_attr(feature = "serde", serde(with = "crate::serde_byte_array"))]
351    pub extra: [u8; 100],
352    /// Volume/phase origin (pixels/voxels) or origin of subvolume
353    pub origin: [f32; 3],
354    /// Must contain "MAP " to identify file type
355    pub map: [u8; 4],
356    /// Machine stamp that encodes byte order of data.
357    ///
358    /// Little-endian files use `0x44 0x44 0x00 0x00`.
359    pub machst: [u8; 4],
360    /// RMS deviation of map from mean density
361    pub rms: f32,
362    /// Number of valid labels in `label` field (0–10)
363    pub nlabl: i32,
364    /// Ten text labels of 80 bytes each.
365    #[cfg_attr(feature = "serde", serde(with = "crate::serde_byte_array"))]
366    pub label: [u8; 800],
367}
368
369impl Default for Header {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl Header {
376    #[inline]
377    /// Constructs a default header suitable for a new MRC file.
378    ///
379    /// All dimensions are zero, the mode is 32-bit float, and
380    /// cell angles are 90°. Other fields are set to safe neutral values.
381    ///
382    /// # Endianness
383    /// Per crate policy, new MRC files are always written in little-endian format.
384    /// This constructor sets `machst` to little-endian by default and initializes
385    /// `nversion` to `20141` (latest MRC2014 update).
386    #[must_use]
387    pub const fn new() -> Self {
388        Self {
389            nx: 0,
390            ny: 0,
391            nz: 0,
392            mode: 2, // 32-bit floating point
393            nxstart: 0,
394            nystart: 0,
395            nzstart: 0,
396            mx: 0,
397            my: 0,
398            mz: 0,
399            xlen: 1.0, // Avoid division by zero.
400            ylen: 1.0,
401            zlen: 1.0,
402            alpha: 90.0,
403            beta: 90.0,
404            gamma: 90.0,
405            mapc: 1,     // Column → X
406            mapr: 2,     // Row    → Y
407            maps: 3,     // Section→ Z
408            dmin: 0.0,   // Set higher than dmax to indicate not well-determined
409            dmax: -1.0,  // Set lower than dmin to indicate not well-determined
410            dmean: -2.0, // Less than both to indicate not well-determined
411            ispg: 1,     // P1 space group.
412            nsymbt: 0,
413            extra: DEFAULT_EXTRA,
414            origin: [0.0; 3],
415            map: *b"MAP ",
416            machst: [0x44, 0x44, 0x00, 0x00], // Little-endian (crate policy for new files)
417            rms: -1.0,                        // Negative indicates not well-determined
418            nlabl: 0,
419            label: [0; 800],
420        }
421    }
422
423    #[inline]
424    /// Offset, in bytes, from file start to the first voxel value.
425    ///
426    /// Returns `1024` when `nsymbt` is negative (to avoid integer wrap-around
427    /// on malformed headers).
428    ///
429    /// ```
430    /// use mrc::Header;
431    /// let h = Header::new();
432    /// assert_eq!(h.data_offset(), 1024);
433    /// ```
434    pub const fn data_offset(&self) -> usize {
435        if self.nsymbt < 0 {
436            1024
437        } else {
438            1024 + self.nsymbt as usize
439        }
440    }
441
442    #[inline]
443    /// Size, in bytes, of the voxel data block.
444    ///
445    /// Returns `None` if the dimensions are so large that the calculation
446    /// overflows `usize`.
447    ///
448    /// ```
449    /// use mrc::Header;
450    /// let mut h = Header::new();
451    /// h.nx = 64; h.ny = 64; h.nz = 32;
452    /// h.mode = 2; // Float32 → 4 bytes per voxel
453    /// assert_eq!(h.data_size(), Some(64 * 64 * 32 * 4));
454    /// ```
455    pub fn data_size(&self) -> Option<usize> {
456        let nx = self.nx.max(0) as usize;
457        let ny = self.ny.max(0) as usize;
458        let nz = self.nz.max(0) as usize;
459        match Mode::from_i32(self.mode) {
460            Some(mode) => {
461                match mode {
462                    // For Packed4Bit, each row is padded to a whole byte boundary:
463                    // row_bytes = nx.div_ceil(2), total = ny * row_bytes * nz
464                    Mode::Packed4Bit => {
465                        let row_bytes = nx.div_ceil(2);
466                        ny.checked_mul(row_bytes)?.checked_mul(nz)
467                    }
468                    _ => nx
469                        .checked_mul(ny)?
470                        .checked_mul(nz)?
471                        .checked_mul(mode.byte_size()),
472                }
473            }
474            None => None, // unknown/unsupported mode
475        }
476    }
477
478    #[inline]
479    /// True when dimensions are positive and mode is supported.
480    ///
481    /// ```
482    /// use mrc::Header;
483    /// let h = Header::new();
484    /// // Default header has zero dimensions → invalid
485    /// assert!(!h.validate());
486    /// ```
487    pub fn validate(&self) -> bool {
488        self.validate_detailed().is_ok()
489    }
490
491    #[inline]
492    /// Detailed header validation returning specific error information.
493    ///
494    /// ```
495    /// use mrc::Header;
496    /// let h = Header::new();
497    /// match h.validate_detailed() {
498    ///     Err(e) => assert!(e.to_string().contains("dimensions")),
499    ///     Ok(()) => unreachable!(),
500    /// }
501    /// ```
502    pub fn validate_detailed(&self) -> Result<(), crate::HeaderValidationError> {
503        use crate::HeaderValidationError;
504
505        if self.nx <= 0 || self.ny <= 0 || self.nz <= 0 {
506            return Err(HeaderValidationError::InvalidDimensions {
507                nx: self.nx,
508                ny: self.ny,
509                nz: self.nz,
510            });
511        }
512
513        if Mode::from_i32(self.mode).is_none() {
514            return Err(HeaderValidationError::UnsupportedMode(self.mode));
515        }
516
517        if !self.validate_map() {
518            return Err(HeaderValidationError::InvalidMap(self.map));
519        }
520
521        if !(self.ispg == 0
522            || (self.ispg >= 1 && self.ispg <= 230)
523            || (self.ispg >= 400 && self.ispg <= 630))
524        {
525            return Err(HeaderValidationError::InvalidIspg(self.ispg));
526        }
527
528        if !(matches!(self.mapc, 1..=3)
529            && matches!(self.mapr, 1..=3)
530            && matches!(self.maps, 1..=3)
531            && self.mapc != self.mapr
532            && self.mapc != self.maps
533            && self.mapr != self.maps)
534        {
535            return Err(HeaderValidationError::InvalidAxisMapping {
536                mapc: self.mapc,
537                mapr: self.mapr,
538                maps: self.maps,
539            });
540        }
541
542        if self.nsymbt < 0 {
543            return Err(HeaderValidationError::InvalidNsymbt(self.nsymbt));
544        }
545
546        if self.nlabl < 0 || self.nlabl > 10 {
547            return Err(HeaderValidationError::InvalidNlabl(self.nlabl));
548        }
549
550        // Label sequence validation: nlabl must match actual non-empty labels,
551        // and no empty labels may appear between filled ones.
552        let actual_labels = self.count_non_empty_labels();
553        if actual_labels != self.nlabl as usize {
554            return Err(HeaderValidationError::LabelCountMismatch {
555                nlabl: self.nlabl,
556                actual: actual_labels as i32,
557            });
558        }
559        for i in 0..self.nlabl as usize {
560            if self.label_is_empty(i) {
561                return Err(HeaderValidationError::EmptyLabelBeforeFilled { index: i as i32 });
562            }
563        }
564
565        let nversion = self.nversion();
566        if nversion != 0 && nversion != 20140 && nversion != 20141 {
567            return Err(HeaderValidationError::InvalidNversion(nversion));
568        }
569
570        if self.mx <= 0 || self.my <= 0 || self.mz <= 0 {
571            return Err(HeaderValidationError::InvalidSampling {
572                mx: self.mx,
573                my: self.my,
574                mz: self.mz,
575            });
576        }
577
578        if self.ispg >= 400 && self.ispg <= 630 && self.mz != 0 && self.nz % self.mz != 0 {
579            return Err(HeaderValidationError::InvalidVolumeStack {
580                nz: self.nz,
581                mz: self.mz,
582                ispg: self.ispg,
583            });
584        }
585
586        Ok(())
587    }
588
589    /// Permissive validation that returns warnings instead of hard errors
590    /// for most non-critical issues.
591    ///
592    /// Only **fatal** problems (dimensions ≤ 0 or completely unsupported mode)
593    /// produce an `Err`. Everything else is collected as a human-readable
594    /// warning string.
595    ///
596    /// # Examples
597    ///
598    /// ```
599    /// use mrc::Header;
600    /// let mut h = Header::new();
601    /// h.nx = 64; h.ny = 64; h.nz = 32;
602    /// h.mx = 64; h.my = 64; h.mz = 32;
603    /// h.mode = 2;
604    /// assert!(h.validate_permissive().is_ok());
605    /// ```
606    pub fn validate_permissive(&self) -> Result<Vec<String>, crate::HeaderValidationError> {
607        use crate::HeaderValidationError;
608        let mut warnings = Vec::new();
609
610        if self.nx <= 0 || self.ny <= 0 || self.nz <= 0 {
611            return Err(HeaderValidationError::InvalidDimensions {
612                nx: self.nx,
613                ny: self.ny,
614                nz: self.nz,
615            });
616        }
617
618        if Mode::from_i32(self.mode).is_none() {
619            return Err(HeaderValidationError::UnsupportedMode(self.mode));
620        }
621
622        if !self.validate_map() {
623            warnings.push(format!(
624                "MAP field is non-standard: {:?}",
625                String::from_utf8_lossy(&self.map)
626            ));
627        }
628
629        if !(self.ispg == 0
630            || (self.ispg >= 1 && self.ispg <= 230)
631            || (self.ispg >= 400 && self.ispg <= 630))
632        {
633            warnings.push(format!(
634                "ISPG {} is outside the standard ranges (0, 1-230, 400-630)",
635                self.ispg
636            ));
637        }
638
639        if !(self.mapc != 0
640            && self.mapc.abs() <= 3
641            && self.mapr != 0
642            && self.mapr.abs() <= 3
643            && self.maps != 0
644            && self.maps.abs() <= 3
645            && self.mapc.abs() != self.mapr.abs()
646            && self.mapc.abs() != self.maps.abs()
647            && self.mapr.abs() != self.maps.abs())
648        {
649            warnings.push(format!(
650                "Axis mapping ({}, {}, {}) is not a valid permutation of axis indices",
651                self.mapc, self.mapr, self.maps
652            ));
653        } else if self.mapr == -2 {
654            warnings.push("mapr = -2 indicates Y-inverted image data (IMOD convention)".into());
655        }
656
657        if self.nsymbt < 0 {
658            warnings.push(format!("NSYMBT is negative ({})", self.nsymbt));
659        }
660
661        if self.nlabl < 0 || self.nlabl > 10 {
662            warnings.push(format!("NLABL {} is outside 0-10", self.nlabl));
663        }
664
665        let nversion = self.nversion();
666        if nversion != 20140 && nversion != 20141 {
667            warnings.push(format!("NVERSION {} is not 20140 or 20141", nversion));
668        }
669
670        if self.mx <= 0 || self.my <= 0 || self.mz <= 0 {
671            warnings.push(format!(
672                "Sampling (mx={}, my={}, mz={}) is not all positive",
673                self.mx, self.my, self.mz
674            ));
675        }
676
677        if self.ispg >= 400 && self.ispg <= 630 && self.mz != 0 && self.nz % self.mz != 0 {
678            warnings.push(format!(
679                "Volume stack: nz ({}) is not divisible by mz ({}) for ispg={}",
680                self.nz, self.mz, self.ispg
681            ));
682        }
683
684        Ok(warnings)
685    }
686
687    #[inline]
688    /// Validate the MAP field, allowing for legacy variants.
689    ///
690    /// Standard MRC2014 requires "MAP ", but some legacy files may use:
691    /// - "MAP\0" (null-terminated)
692    /// - "MAPI" (older format)
693    /// - All zeros (uninitialized)
694    fn validate_map(&self) -> bool {
695        // Standard MRC2014 format
696        if self.map == *b"MAP " {
697            return true;
698        }
699        // Accept legacy variants: "MAP\0" or "MAPI"
700        if &self.map[..3] == b"MAP"
701            && (self.map[3] == b' ' || self.map[3] == 0 || self.map[3] == b'I')
702        {
703            return true;
704        }
705        // Accept all zeros (uninitialized, common in some generated files)
706        if self.map == [0; 4] {
707            return true;
708        }
709        false
710    }
711
712    #[inline]
713    /// Reads the 4-byte EXTTYP identifier stored in `extra[8..12]`.
714    ///
715    /// EXTTYP is a 4-byte ASCII string indicating the type of extended header.
716    /// Common values: "CCP4", "MRCO", "SERI", "AGAR", "FEI1", "FEI2", "HDF5".
717    ///
718    /// ```
719    /// use mrc::Header;
720    /// let mut h = Header::new();
721    /// h.set_exttyp(*b"CCP4");
722    /// assert_eq!(h.exttyp(), *b"CCP4");
723    /// ```
724    pub fn exttyp(&self) -> [u8; 4] {
725        [
726            self.extra[OFFSET_EXTTYP - OFFSET_EXTRA],
727            self.extra[OFFSET_EXTTYP - OFFSET_EXTRA + 1],
728            self.extra[OFFSET_EXTTYP - OFFSET_EXTRA + 2],
729            self.extra[OFFSET_EXTTYP - OFFSET_EXTRA + 3],
730        ]
731    }
732
733    #[inline]
734    /// Stores the 4-byte EXTTYP identifier into `extra[8..12]`.
735    ///
736    /// EXTTYP is a 4-byte ASCII string indicating the type of extended header.
737    ///
738    /// # Examples
739    ///
740    /// ```
741    /// use mrc::Header;
742    /// let mut h = Header::new();
743    /// h.set_exttyp(*b"CCP4");
744    /// assert_eq!(h.exttyp(), *b"CCP4");
745    /// ```
746    pub fn set_exttyp(&mut self, value: [u8; 4]) {
747        let start = OFFSET_EXTTYP - OFFSET_EXTRA;
748        self.extra[start..start + 4].copy_from_slice(&value);
749    }
750
751    #[inline]
752    /// Interprets EXTTYP as an ASCII string.
753    ///
754    /// # Examples
755    ///
756    /// ```
757    /// use mrc::Header;
758    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
759    /// let mut h = Header::new();
760    /// h.set_exttyp(*b"FEI1");
761    /// assert_eq!(h.exttyp_str()?, "FEI1");
762    /// # Ok(())
763    /// # }
764    /// ```
765    pub fn exttyp_str(&self) -> Result<&str, core::str::Utf8Error> {
766        let start = OFFSET_EXTTYP - OFFSET_EXTRA;
767        core::str::from_utf8(&self.extra[start..start + 4])
768    }
769
770    #[inline]
771    /// Sets EXTTYP from a 4-character ASCII string.
772    ///
773    /// # Examples
774    ///
775    /// ```
776    /// use mrc::Header;
777    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
778    /// let mut h = Header::new();
779    /// h.set_exttyp_str("FEI1")?;
780    /// assert_eq!(h.exttyp(), *b"FEI1");
781    /// # Ok(())
782    /// # }
783    /// ```
784    pub fn set_exttyp_str(&mut self, value: &str) -> Result<(), &'static str> {
785        if value.len() != 4 {
786            return Err("EXTTYP must be exactly 4 characters");
787        }
788        let bytes = value.as_bytes();
789        let start = OFFSET_EXTTYP - OFFSET_EXTRA;
790        self.extra[start..start + 4].copy_from_slice(bytes);
791        Ok(())
792    }
793
794    #[inline]
795    /// Reads the 4-byte NVERSION number stored in `extra[12..16]`.
796    ///
797    /// This value is a numeric i32 and respects the file's endianness.
798    ///
799    /// ```
800    /// use mrc::Header;
801    /// let h = Header::new();
802    /// assert_eq!(h.nversion(), 20141);
803    /// ```
804    pub fn nversion(&self) -> i32 {
805        use crate::engine::codec::EndianCodec;
806        let file_endian = self.detect_endian();
807        let start = OFFSET_NVERSION - OFFSET_EXTRA;
808        i32::decode(&self.extra[start..start + 4], 0, file_endian)
809    }
810
811    #[inline]
812    /// Stores the 4-byte NVERSION number into `extra[12..16]`.
813    ///
814    /// This value is a numeric i32 and respects the file's endianness.
815    ///
816    /// # Examples
817    ///
818    /// ```
819    /// use mrc::Header;
820    /// let mut h = Header::new();
821    /// h.set_nversion(20140);
822    /// assert_eq!(h.nversion(), 20140);
823    /// ```
824    pub fn set_nversion(&mut self, value: i32) {
825        use crate::engine::codec::EndianCodec;
826        let file_endian = self.detect_endian();
827        let start = OFFSET_NVERSION - OFFSET_EXTRA;
828        value.encode(&mut self.extra[start..start + 4], 0, file_endian);
829    }
830
831    /// Get the list of non-empty text labels.
832    ///
833    /// Returns up to `nlabl` labels, each trimmed of trailing whitespace.
834    ///
835    /// ```
836    /// use mrc::Header;
837    /// let mut h = Header::new();
838    /// h.add_label("my sample");
839    /// h.add_label("defocus series");
840    /// let labels = h.get_labels();
841    /// assert_eq!(labels, vec!["my sample", "defocus series"]);
842    /// ```
843    pub fn get_labels(&self) -> Vec<String> {
844        let count = self.nlabl.clamp(0, 10) as usize;
845        let mut labels = Vec::with_capacity(count);
846        for i in 0..count {
847            let start = i * 80;
848            let bytes = &self.label[start..start + 80];
849            let text = String::from_utf8_lossy(bytes);
850            labels.push(text.trim_end().to_string());
851        }
852        labels
853    }
854
855    /// Check whether the i-th label is empty (all whitespace / zeros).
856    fn label_is_empty(&self, index: usize) -> bool {
857        let start = index * 80;
858        self.label[start..start + 80]
859            .iter()
860            .all(|&b| b == 0 || b == b' ')
861    }
862
863    /// Count how many of the 10 label slots contain non-empty text.
864    fn count_non_empty_labels(&self) -> usize {
865        (0..10).filter(|&i| !self.label_is_empty(i)).count()
866    }
867
868    /// Add a text label to the header.
869    ///
870    /// Labels are truncated to 80 bytes and non-printable ASCII characters
871    /// (outside 0x20–0x7E) are replaced with spaces. If 10 labels are already
872    /// stored, the oldest label is dropped (FIFO).
873    ///
874    /// # Examples
875    ///
876    /// ```
877    /// use mrc::Header;
878    /// let mut h = Header::new();
879    /// h.add_label("my sample");
880    /// assert_eq!(h.get_labels(), vec!["my sample"]);
881    /// ```
882    pub fn add_label(&mut self, text: &str) {
883        // Filter to printable ASCII and truncate to 80 bytes
884        let filtered: String = text
885            .chars()
886            .map(|c| {
887                if c.is_ascii_graphic() || c == ' ' {
888                    c
889                } else {
890                    ' '
891                }
892            })
893            .take(80)
894            .collect();
895        let bytes = filtered.as_bytes();
896        let len = bytes.len();
897
898        let count = self.count_non_empty_labels();
899        if count < 10 {
900            // Find the first empty slot
901            let slot = (0..10).find(|&i| self.label_is_empty(i)).unwrap_or(count);
902            let start = slot * 80;
903            self.label[start..start + 80].fill(b' ');
904            self.label[start..start + len].copy_from_slice(bytes);
905        } else {
906            // Shift existing labels up (FIFO) and store in slot 9.
907            // Drop slot 0 (oldest), shift slots 1..9 to 0..8.
908            self.label.copy_within(80..800, 0);
909            let start = 9 * 80;
910            self.label[start..start + 80].fill(b' ');
911            self.label[start..start + len].copy_from_slice(bytes);
912        }
913        self.nlabl = self.count_non_empty_labels() as i32;
914    }
915
916    #[inline]
917    /// Detect the file endianness from the MACHST machine stamp
918    ///
919    /// ```
920    /// use mrc::{Header, FileEndian};
921    /// let h = Header::new();
922    /// assert_eq!(h.detect_endian(), FileEndian::LittleEndian);
923    /// ```
924    pub fn detect_endian(&self) -> crate::FileEndian {
925        crate::FileEndian::from_machst(&self.machst)
926    }
927
928    #[inline]
929    /// Set the file endianness for this header
930    ///
931    /// This sets the MACHST machine stamp to the appropriate value for the
932    /// specified endianness and re-encodes NVERSION so that it remains valid.
933    ///
934    /// # Note
935    /// Per crate policy, new MRC files are always written in little-endian format.
936    /// This method is not intended for creating big-endian files from scratch.
937    ///
938    /// ```
939    /// use mrc::{Header, FileEndian};
940    /// let mut h = Header::new();
941    /// h.set_file_endian(FileEndian::BigEndian);
942    /// assert_eq!(h.detect_endian(), FileEndian::BigEndian);
943    /// ```
944    pub fn set_file_endian(&mut self, endian: crate::FileEndian) {
945        // Preserve the current nversion value before swapping endianness,
946        // then re-encode it in the new byte order.
947        let current_nversion = self.nversion();
948        self.machst = endian.to_machst();
949        self.set_nversion(current_nversion);
950    }
951
952    // -------------------------------------------------------------------------
953    // Volume type introspection (following Python mrcfile conventions)
954    // -------------------------------------------------------------------------
955
956    /// Returns `true` if this is a single 2D image (`nz == 1`).
957    ///
958    /// ```
959    /// use mrc::Header;
960    /// let mut h = Header::new();
961    /// h.nz = 1;
962    /// assert!(h.is_single_image());
963    /// h.nz = 10;
964    /// assert!(!h.is_single_image());
965    /// ```
966    pub fn is_single_image(&self) -> bool {
967        self.nz == 1
968    }
969
970    /// Returns `true` if this is an image stack (`ispg == 0`).
971    ///
972    /// ```
973    /// use mrc::Header;
974    /// let mut h = Header::new();
975    /// h.ispg = 0;
976    /// assert!(h.is_image_stack());
977    /// ```
978    pub fn is_image_stack(&self) -> bool {
979        self.ispg == 0
980    }
981
982    /// Returns `true` if this is a single 3D volume (`ispg != 0` and not a
983    /// volume stack).
984    ///
985    /// # Examples
986    ///
987    /// ```
988    /// use mrc::Header;
989    /// let mut h = Header::new();
990    /// h.ispg = 1;
991    /// assert!(h.is_volume());
992    /// ```
993    pub fn is_volume(&self) -> bool {
994        !self.is_image_stack() && !self.is_volume_stack()
995    }
996
997    /// Returns `true` if this is a volume stack (`ispg` in 401–630).
998    ///
999    /// # Examples
1000    ///
1001    /// ```
1002    /// use mrc::Header;
1003    /// let mut h = Header::new();
1004    /// h.ispg = 401;
1005    /// assert!(h.is_volume_stack());
1006    /// ```
1007    pub fn is_volume_stack(&self) -> bool {
1008        (400..=630).contains(&self.ispg)
1009    }
1010
1011    /// Configure the header as an image stack.
1012    ///
1013    /// Sets `ispg = 0` and `mz = 1`.
1014    ///
1015    /// # Examples
1016    ///
1017    /// ```
1018    /// use mrc::Header;
1019    /// let mut h = Header::new();
1020    /// h.set_image_stack();
1021    /// assert!(h.is_image_stack());
1022    /// ```
1023    pub fn set_image_stack(&mut self) {
1024        self.ispg = 0;
1025        self.mz = 1;
1026    }
1027
1028    /// Configure the header as a single volume.
1029    ///
1030    /// Sets `ispg = 1` and `mz = nz`.
1031    ///
1032    /// # Examples
1033    ///
1034    /// ```
1035    /// use mrc::Header;
1036    /// let mut h = Header::new();
1037    /// h.nz = 32;
1038    /// h.set_volume();
1039    /// assert!(h.is_volume());
1040    /// assert_eq!(h.mz, 32);
1041    /// ```
1042    pub fn set_volume(&mut self) {
1043        self.ispg = 1;
1044        self.mz = self.nz;
1045    }
1046
1047    /// Configure the header as a volume stack.
1048    ///
1049    /// Sets `ispg = 401` and `mz` to the given sub-volume size.
1050    /// `nz` must be divisible by `mz` for the header to validate.
1051    ///
1052    /// # Examples
1053    ///
1054    /// ```
1055    /// use mrc::Header;
1056    /// let mut h = Header::new();
1057    /// h.set_volume_stack(16);
1058    /// assert!(h.is_volume_stack());
1059    /// assert_eq!(h.mz, 16);
1060    /// ```
1061    pub fn set_volume_stack(&mut self, mz: i32) {
1062        self.ispg = 401;
1063        self.mz = mz;
1064    }
1065
1066    // -------------------------------------------------------------------------
1067    // Computed convenience properties
1068    // -------------------------------------------------------------------------
1069
1070    /// Voxel size in Ångströms per pixel, computed as `cella / mxyz`.
1071    ///
1072    /// Returns `[xlen / mx, ylen / my, zlen / mz]`.
1073    /// If any of `mx`, `my`, `mz` is zero, that component returns `0.0`.
1074    ///
1075    /// # Examples
1076    ///
1077    /// ```
1078    /// use mrc::Header;
1079    /// let mut h = Header::new();
1080    /// h.xlen = 10.0; h.mx = 100;
1081    /// h.ylen = 10.0; h.my = 100;
1082    /// h.zlen = 20.0; h.mz = 200;
1083    /// let vs = h.voxel_size();
1084    /// assert!((vs[0] - 0.1).abs() < 1e-6);
1085    /// ```
1086    pub fn voxel_size(&self) -> [f32; 3] {
1087        [
1088            if self.mx == 0 {
1089                0.0
1090            } else {
1091                self.xlen / self.mx as f32
1092            },
1093            if self.my == 0 {
1094                0.0
1095            } else {
1096                self.ylen / self.my as f32
1097            },
1098            if self.mz == 0 {
1099                0.0
1100            } else {
1101                self.zlen / self.mz as f32
1102            },
1103        ]
1104    }
1105
1106    /// Starting grid point / origin offset.
1107    ///
1108    /// Returns `[nxstart, nystart, nzstart]`.
1109    ///
1110    /// # Examples
1111    ///
1112    /// ```
1113    /// use mrc::Header;
1114    /// let mut h = Header::new();
1115    /// h.nxstart = 10; h.nystart = 20; h.nzstart = 30;
1116    /// assert_eq!(h.nstart(), [10, 20, 30]);
1117    /// ```
1118    pub fn nstart(&self) -> [i32; 3] {
1119        [self.nxstart, self.nystart, self.nzstart]
1120    }
1121
1122    /// Cell dimensions (unit cell edge lengths) in ångströms.
1123    ///
1124    /// Returns `[xlen, ylen, zlen]`.
1125    ///
1126    /// # Examples
1127    ///
1128    /// ```
1129    /// use mrc::Header;
1130    /// let h = Header::new();
1131    /// assert_eq!(h.cell_lengths(), [1.0, 1.0, 1.0]);
1132    /// ```
1133    pub fn cell_lengths(&self) -> [f32; 3] {
1134        [self.xlen, self.ylen, self.zlen]
1135    }
1136
1137    /// Cell angles in degrees.
1138    ///
1139    /// Returns `[alpha, beta, gamma]`.
1140    ///
1141    /// # Examples
1142    ///
1143    /// ```
1144    /// use mrc::Header;
1145    /// let h = Header::new();
1146    /// assert_eq!(h.cell_angles(), [90.0, 90.0, 90.0]);
1147    /// ```
1148    pub fn cell_angles(&self) -> [f32; 3] {
1149        [self.alpha, self.beta, self.gamma]
1150    }
1151
1152    /// Logical data shape following Python `mrcfile` conventions.
1153    ///
1154    /// | Type | Shape |
1155    /// |------|-------|
1156    /// | Single image | `(1, 1, ny, nx)` |
1157    /// | Image stack | `(1, nz, ny, nx)` |
1158    /// | Volume | `(1, nz, ny, nx)` |
1159    /// | Volume stack | `(nz / mz, mz, ny, nx)` |
1160    ///
1161    /// # Examples
1162    ///
1163    /// ```
1164    /// use mrc::Header;
1165    /// let mut h = Header::new();
1166    /// h.nx = 64; h.ny = 64; h.nz = 32;
1167    /// // Default ispg=1 (volume) → shape is (1, nz, ny, nx)
1168    /// assert_eq!(h.logical_shape(), [1, 32, 64, 64]);
1169    /// ```
1170    pub fn logical_shape(&self) -> [usize; 4] {
1171        if self.is_volume_stack() && self.mz > 0 {
1172            let nvolumes = (self.nz / self.mz) as usize;
1173            [
1174                nvolumes,
1175                self.mz as usize,
1176                self.ny as usize,
1177                self.nx as usize,
1178            ]
1179        } else if self.is_volume_stack() {
1180            // Volume stack with mz ≤ 0 is degenerate — match the Err from
1181            // Reader::volumes() by returning 0 sub-volumes.
1182            [0, 0, self.ny as usize, self.nx as usize]
1183        } else {
1184            [1, self.nz as usize, self.ny as usize, self.nx as usize]
1185        }
1186    }
1187
1188    /// Sampling rates (mx, my, mz).
1189    ///
1190    /// # Examples
1191    ///
1192    /// ```
1193    /// use mrc::Header;
1194    /// let mut h = Header::new();
1195    /// h.mx = 100; h.my = 100; h.mz = 50;
1196    /// assert_eq!(h.sampling(), [100, 100, 50]);
1197    /// ```
1198    #[inline]
1199    pub fn sampling(&self) -> [i32; 3] {
1200        [self.mx, self.my, self.mz]
1201    }
1202
1203    /// Header density statistics `(dmin, dmax, dmean, rms)`.
1204    ///
1205    /// # Examples
1206    ///
1207    /// ```
1208    /// use mrc::Header;
1209    /// let h = Header::new();
1210    /// assert_eq!(h.density_stats(), (0.0, -1.0, -2.0, -1.0));
1211    /// ```
1212    #[inline]
1213    pub fn density_stats(&self) -> (f32, f32, f32, f32) {
1214        (self.dmin, self.dmax, self.dmean, self.rms)
1215    }
1216
1217    /// Returns `true` when the MAP field is exactly `"MAP "` (MRC-2014 standard).
1218    ///
1219    /// # Examples
1220    ///
1221    /// ```
1222    /// use mrc::Header;
1223    /// let h = Header::new();
1224    /// assert!(h.is_standard_map());
1225    /// ```
1226    #[inline]
1227    pub fn is_standard_map(&self) -> bool {
1228        self.map == *b"MAP "
1229    }
1230
1231    /// Get the i-th text label as a trimmed `&str`, or `None` if the label slot
1232    /// is empty or `i >= nlabl`.
1233    ///
1234    /// Labels are 80-byte fixed-width fields.  Trailing whitespace is trimmed.
1235    /// Non-UTF-8 bytes are replaced with `U+FFFD` (this is rare in practice).
1236    ///
1237    /// # Examples
1238    ///
1239    /// ```
1240    /// use mrc::Header;
1241    /// let mut h = Header::new();
1242    /// h.add_label("my sample");
1243    /// assert_eq!(h.label_at(0), Some("my sample"));
1244    /// ```
1245    pub fn label_at(&self, index: usize) -> Option<&str> {
1246        if index >= self.nlabl.clamp(0, 10) as usize || self.label_is_empty(index) {
1247            return None;
1248        }
1249        let start = index * 80;
1250        let end = start
1251            + self.label[start..start + 80]
1252                .iter()
1253                .rposition(|&b| b != b' ')
1254                .map_or(0, |p| p + 1);
1255        let trimmed = core::str::from_utf8(&self.label[start..end]).unwrap_or("<invalid utf-8>");
1256        Some(trimmed)
1257    }
1258
1259    /// Compute the unit cell volume in cubic ångströms.
1260    ///
1261    /// Uses the general formula for a triclinic cell:
1262    ///
1263    /// `V = a * b * c * sqrt(1 - cos²α - cos²β - cos²γ + 2 * cosα * cosβ * cosγ)`
1264    ///
1265    /// where `a`, `b`, `c` are cell lengths and `α`, `β`, `γ` are cell angles.
1266    /// Returns `0.0` for degenerate cells (any length ≤ 0).
1267    ///
1268    /// # Examples
1269    ///
1270    /// ```
1271    /// use mrc::Header;
1272    /// let mut h = Header::new();
1273    /// h.xlen = 10.0; h.ylen = 10.0; h.zlen = 10.0;
1274    /// let vol = h.cell_volume();
1275    /// assert!((vol - 1000.0).abs() < 1e-6);
1276    /// ```
1277    pub fn cell_volume(&self) -> f64 {
1278        let a = self.xlen as f64;
1279        let b = self.ylen as f64;
1280        let c = self.zlen as f64;
1281        if a <= 0.0 || b <= 0.0 || c <= 0.0 {
1282            return 0.0;
1283        }
1284        let alpha = self.alpha as f64 * (core::f64::consts::PI / 180.0);
1285        let beta = self.beta as f64 * (core::f64::consts::PI / 180.0);
1286        let gamma = self.gamma as f64 * (core::f64::consts::PI / 180.0);
1287        let cos_a = alpha.cos();
1288        let cos_b = beta.cos();
1289        let cos_g = gamma.cos();
1290        a * b
1291            * c
1292            * (1.0 - cos_a * cos_a - cos_b * cos_b - cos_g * cos_g + 2.0 * cos_a * cos_b * cos_g)
1293                .sqrt()
1294    }
1295
1296    /// Decode header from raw bytes with correct endianness.
1297    ///
1298    /// Endianness is detected from the MACHST field and applied automatically.
1299    /// If the detected endianness produces an invalid MODE value, the opposite
1300    /// endianness is tried as a fallback (matching the behaviour of the
1301    /// reference Python `mrcfile` library).
1302    ///
1303    /// # Examples
1304    ///
1305    /// ```
1306    /// use mrc::Header;
1307    /// let mut raw = [0u8; 1024];
1308    /// raw[0..4].copy_from_slice(&(64i32).to_le_bytes());
1309    /// raw[4..8].copy_from_slice(&(64i32).to_le_bytes());
1310    /// raw[8..12].copy_from_slice(&(1i32).to_le_bytes());
1311    /// raw[12..16].copy_from_slice(&(2i32).to_le_bytes());
1312    /// raw[208..212].copy_from_slice(b"MAP ");
1313    /// raw[212..216].copy_from_slice(&[0x44, 0x44, 0x00, 0x00]);
1314    /// let h = Header::decode_from_bytes(&raw);
1315    /// assert_eq!(h.nx, 64);
1316    /// ```
1317    pub fn decode_from_bytes(bytes: &[u8; 1024]) -> Self {
1318        Self::decode_from_bytes_with_info(bytes).0
1319    }
1320}
1321
1322/// Structured warning emitted when the MACHST byte-order stamp does not
1323/// match the actual data endianness, and the decoder had to fall back.
1324#[doc(hidden)]
1325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1326pub enum EndianFallbackWarning {
1327    /// MACHST says little-endian but MODE is only valid as big-endian.
1328    MachstLeDataBe,
1329    /// MACHST says big-endian but MODE is only valid as little-endian.
1330    MachstBeDataLe,
1331}
1332
1333impl core::fmt::Display for EndianFallbackWarning {
1334    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1335        match self {
1336            Self::MachstLeDataBe => write!(
1337                f,
1338                "MACHST indicates little-endian but MODE is valid only as \
1339                 big-endian; using big-endian"
1340            ),
1341            Self::MachstBeDataLe => write!(
1342                f,
1343                "MACHST indicates big-endian but MODE is valid only as \
1344                 little-endian; using little-endian"
1345            ),
1346        }
1347    }
1348}
1349
1350// ============================================================================
1351// Macro: generate decode + encode for all numeric header fields from one list.
1352// Defines `decode_numeric_fields!` and `encode_numeric_fields!` helpers that
1353// expand to the field-by-field read/write calls.  The field order in the
1354// macro matches the MRC2014 specification layout.
1355// ============================================================================
1356macro_rules! header_numeric_fields {
1357    ( $( ($field:ident, $offset:ident, $ty:ty) ),+ $(,)? ) => {
1358        macro_rules! decode_numeric_fields {
1359            ($_h:ident, $_b:expr, $_e:expr) => {
1360                $( $_h.$field = <$ty>::decode($_b, $offset, $_e); )+
1361            }
1362        }
1363        macro_rules! encode_numeric_fields {
1364            ($_h:expr, $_o:expr, $_e:expr) => {
1365                $( $_h.$field.encode($_o, $offset, $_e); )+
1366            }
1367        }
1368    }
1369}
1370
1371header_numeric_fields! {
1372    (nx, OFFSET_NX, i32),
1373    (ny, OFFSET_NY, i32),
1374    (nz, OFFSET_NZ, i32),
1375    (mode, OFFSET_MODE, i32),
1376    (nxstart, OFFSET_NXSTART, i32),
1377    (nystart, OFFSET_NYSTART, i32),
1378    (nzstart, OFFSET_NZSTART, i32),
1379    (mx, OFFSET_MX, i32),
1380    (my, OFFSET_MY, i32),
1381    (mz, OFFSET_MZ, i32),
1382    (xlen, OFFSET_XLEN, f32),
1383    (ylen, OFFSET_YLEN, f32),
1384    (zlen, OFFSET_ZLEN, f32),
1385    (alpha, OFFSET_ALPHA, f32),
1386    (beta, OFFSET_BETA, f32),
1387    (gamma, OFFSET_GAMMA, f32),
1388    (mapc, OFFSET_MAPC, i32),
1389    (mapr, OFFSET_MAPR, i32),
1390    (maps, OFFSET_MAPS, i32),
1391    (dmin, OFFSET_DMIN, f32),
1392    (dmax, OFFSET_DMAX, f32),
1393    (dmean, OFFSET_DMEAN, f32),
1394    (ispg, OFFSET_ISPG, i32),
1395    (nsymbt, OFFSET_NSYMBT, i32),
1396}
1397
1398impl Header {
1399    /// Decode header and return any byte-order fallback that occurred.
1400    ///
1401    /// Returns `(header, warning)` where `warning` is `Some` if the MACHST
1402    /// indicated one endianness but the MODE field was only valid under the
1403    /// opposite endianness.
1404    ///
1405    /// # Examples
1406    ///
1407    /// ```
1408    /// use mrc::Header;
1409    /// let mut raw = [0u8; 1024];
1410    /// raw[0..4].copy_from_slice(&(64i32).to_le_bytes());
1411    /// raw[4..8].copy_from_slice(&(64i32).to_le_bytes());
1412    /// raw[8..12].copy_from_slice(&(1i32).to_le_bytes());
1413    /// raw[12..16].copy_from_slice(&(2i32).to_le_bytes());
1414    /// raw[208..212].copy_from_slice(b"MAP ");
1415    /// raw[212..216].copy_from_slice(&[0x44, 0x44, 0x00, 0x00]);
1416    /// let (h, warn) = Header::decode_from_bytes_with_info(&raw);
1417    /// assert_eq!(h.nx, 64);
1418    /// assert!(warn.is_none());
1419    /// ```
1420    pub fn decode_from_bytes_with_info(
1421        bytes: &[u8; 1024],
1422    ) -> (Self, Option<EndianFallbackWarning>) {
1423        use crate::engine::endian::FileEndian;
1424
1425        let machst = [
1426            bytes[OFFSET_MACHST],
1427            bytes[OFFSET_MACHST + 1],
1428            bytes[OFFSET_MACHST + 2],
1429            bytes[OFFSET_MACHST + 3],
1430        ];
1431        let detected = FileEndian::from_machst(&machst);
1432
1433        let header = Self::decode_with_endian(bytes, detected);
1434
1435        // Byte-order fallback: if MODE is invalid under detected endianness,
1436        // try the opposite endianness. This handles malformed files where
1437        // the MACHST is wrong but the rest of the file is correctly encoded.
1438        if crate::Mode::from_i32(header.mode).is_none() {
1439            let opposite = detected.opposite();
1440            let candidate = Self::decode_with_endian(bytes, opposite);
1441            if crate::Mode::from_i32(candidate.mode).is_some() {
1442                let warning = match detected {
1443                    FileEndian::LittleEndian => EndianFallbackWarning::MachstLeDataBe,
1444                    FileEndian::BigEndian => EndianFallbackWarning::MachstBeDataLe,
1445                };
1446                return (candidate, Some(warning));
1447            }
1448        }
1449
1450        (header, None)
1451    }
1452
1453    fn decode_with_endian(bytes: &[u8; 1024], file_endian: crate::FileEndian) -> Self {
1454        use crate::engine::codec::EndianCodec;
1455
1456        let mut header = Self::new();
1457
1458        decode_numeric_fields!(header, bytes, file_endian);
1459
1460        header
1461            .extra
1462            .copy_from_slice(&bytes[OFFSET_EXTRA..OFFSET_ORIGIN]);
1463
1464        header.origin[0] = f32::decode(bytes, OFFSET_ORIGIN, file_endian);
1465        header.origin[1] = f32::decode(bytes, OFFSET_ORIGIN + 4, file_endian);
1466        header.origin[2] = f32::decode(bytes, OFFSET_ORIGIN + 8, file_endian);
1467
1468        header
1469            .map
1470            .copy_from_slice(&bytes[OFFSET_MAP..OFFSET_MACHST]);
1471        header
1472            .machst
1473            .copy_from_slice(&bytes[OFFSET_MACHST..OFFSET_RMS]);
1474
1475        header.rms = f32::decode(bytes, OFFSET_RMS, file_endian);
1476        header.nlabl = i32::decode(bytes, OFFSET_NLABL, file_endian);
1477        header.label.copy_from_slice(&bytes[OFFSET_LABEL..1024]);
1478
1479        header
1480    }
1481
1482    /// Encode header to raw bytes with correct endianness.
1483    ///
1484    /// Endianness is determined from the MACHST field and applied automatically.
1485    ///
1486    /// # Examples
1487    ///
1488    /// ```
1489    /// use mrc::Header;
1490    /// let h = Header::new();
1491    /// let mut encoded = [0u8; 1024];
1492    /// h.encode_to_bytes(&mut encoded);
1493    /// assert_eq!(&encoded[208..212], b"MAP ");
1494    /// ```
1495    pub fn encode_to_bytes(&self, out: &mut [u8; 1024]) {
1496        use crate::engine::codec::EndianCodec;
1497
1498        let file_endian = self.detect_endian();
1499
1500        // Write all numeric fields via the shared macro (22 fields)
1501        encode_numeric_fields!(self, out, file_endian);
1502
1503        // Write extra bytes
1504        out[OFFSET_EXTRA..OFFSET_ORIGIN].copy_from_slice(&self.extra);
1505
1506        // Write origin coordinates
1507        self.origin[0].encode(out, OFFSET_ORIGIN, file_endian);
1508        self.origin[1].encode(out, OFFSET_ORIGIN + 4, file_endian);
1509        self.origin[2].encode(out, OFFSET_ORIGIN + 8, file_endian);
1510
1511        // Write MAP identifier - ASCII, no endian conversion
1512        out[OFFSET_MAP..OFFSET_MACHST].copy_from_slice(&self.map);
1513
1514        // Write MACHST - byte signature, no endian conversion
1515        out[OFFSET_MACHST..OFFSET_RMS].copy_from_slice(&self.machst);
1516
1517        // Write RMS
1518        self.rms.encode(out, OFFSET_RMS, file_endian);
1519
1520        // Write label count
1521        self.nlabl.encode(out, OFFSET_NLABL, file_endian);
1522
1523        // Write labels - ASCII, no endian conversion
1524        out[OFFSET_LABEL..1024].copy_from_slice(&self.label);
1525    }
1526}
1527
1528/// IMOD-specific metadata parsed from the `extra` block (bytes 56-63).
1529///
1530/// IMOD stores metadata in the MRC-2014 `extra` free-form area at offsets
1531/// 152-159. The `imodStamp` at offset 152 spells `"IMOD"` in ASCII and
1532/// identifies the file as IMOD-created. The `imodFlags` at offset 156
1533/// contain bit flags for signedness, origin convention, etc.
1534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1535pub struct ImodInfo {
1536    /// When `true`, Mode 0 (Int8) bytes are signed (matching MRC-2014).
1537    /// When `false`, bytes are unsigned (IMOD legacy convention).
1538    pub bytes_are_signed: bool,
1539}
1540
1541impl Header {
1542    /// Detect IMOD-specific metadata from the `extra` bytes.
1543    ///
1544    /// Returns `None` if the `imodStamp` is not present (file is not
1545    /// IMOD-created or uses a very old IMOD version).
1546    ///
1547    /// When this returns `Some`, the `imodFlags` at `extra[60]` indicate
1548    /// whether Mode 0 bytes are signed or unsigned:
1549    /// - `bytes_are_signed: true` → bit 0 set → standard MRC-2014 signed bytes
1550    /// - `bytes_are_signed: false` → bit 0 clear → IMOD legacy unsigned bytes
1551    ///
1552    /// # Examples
1553    ///
1554    /// ```
1555    /// use mrc::Header;
1556    /// let mut h = Header::new();
1557    /// h.extra[56..60].copy_from_slice(b"IMOD");
1558    /// assert!(h.detect_imod().is_some());
1559    /// ```
1560    pub fn detect_imod(&self) -> Option<ImodInfo> {
1561        // imodStamp at extra[56..60] = little-endian "IMOD" (1146047817)
1562        if self.extra[56..60] == [0x49, 0x4D, 0x4F, 0x44] {
1563            Some(ImodInfo {
1564                bytes_are_signed: (self.extra[60] & 1) != 0,
1565            })
1566        } else {
1567            None
1568        }
1569    }
1570
1571    /// Returns `true` when `mapr == -2`, indicating Y-inverted image data
1572    /// (an IMOD convention not part of standard MRC-2014).
1573    ///
1574    /// # Examples
1575    ///
1576    /// ```
1577    /// use mrc::Header;
1578    /// let mut h = Header::new();
1579    /// h.mapr = -2;
1580    /// assert!(h.is_y_inverted());
1581    /// ```
1582    pub fn is_y_inverted(&self) -> bool {
1583        self.mapr == -2
1584    }
1585}
1586
1587/// IMOD image type classification from the `idtype` field.
1588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1589#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1590#[non_exhaustive]
1591pub enum ImodImageType {
1592    /// Single image or untitled stack.
1593    Mono,
1594    /// Single tilt series.
1595    Tilt,
1596    /// Multiple tilt series.
1597    Tilts,
1598    /// Linear interpolation data.
1599    Lina,
1600    /// Linear interpolation data with extra parameters.
1601    Lins,
1602}
1603
1604/// IMOD-specific metadata parsed from the main header's `extra` bytes.
1605///
1606/// Returned by [`parse_imod_metadata`]. Only populated when the `imodStamp`
1607/// is present, indicating an IMOD-created file.
1608#[derive(Debug, Clone, PartialEq)]
1609#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1610pub struct ImodMetadata {
1611    /// Whether Mode 0 bytes are signed (true) or unsigned (false).
1612    pub bytes_are_signed: bool,
1613    /// Raw IMOD flags from `extra[60..62]` (bit 0 = signed mode 0).
1614    pub imod_flags: u16,
1615    /// Image stack type classification.
1616    pub image_type: ImodImageType,
1617    /// Tilt axis (1=X, 2=Y, 3=Z).
1618    pub tilt_axis: u8,
1619    /// Tilt increment in degrees (`vd1 / 100.0`).
1620    pub tilt_increment: f32,
1621    /// Starting tilt angle in degrees (`vd2 / 100.0`).
1622    pub start_angle: f32,
1623    /// Original tilt angles `[tilt_x, tilt_y, tilt_z]`.
1624    pub original_angles: [f32; 3],
1625    /// Current tilt angles `[tilt_x, tilt_y, tilt_z]`.
1626    pub current_angles: [f32; 3],
1627    /// X origin in pixels (`extra[0..4]` as f32 LE).
1628    pub x_origin: f32,
1629    /// Y origin in pixels (`extra[4..8]` as f32 LE).
1630    pub y_origin: f32,
1631    /// Z origin in pixels (`extra[8..12]` as f32 LE).
1632    pub z_origin: f32,
1633    /// Cell size in X dimension in Å (`extra[12..16]` as f32 LE).
1634    pub x_cell_size: f32,
1635    /// Cell size in Y dimension in Å (`extra[16..20]` as f32 LE).
1636    pub y_cell_size: f32,
1637    /// Cell size in Z dimension in Å (`extra[20..24]` as f32 LE).
1638    pub z_cell_size: f32,
1639}
1640
1641/// Parse IMOD metadata from the main header's `extra` bytes.
1642///
1643/// Returns `None` if the `imodStamp` is not present (file is not IMOD-created).
1644///
1645/// Fields are decoded from little-endian integers and floats stored in the
1646/// MRC-2014 `extra` free-form block (offsets 152–195).
1647pub fn parse_imod_metadata(header: &Header) -> Option<ImodMetadata> {
1648    // Check for imodStamp
1649    if header.extra[56..60] != [0x49, 0x4D, 0x4F, 0x44] {
1650        return None;
1651    }
1652
1653    let le_i16 = |offset: usize| -> i16 {
1654        i16::from_le_bytes([header.extra[offset], header.extra[offset + 1]])
1655    };
1656
1657    let le_f32 = |offset: usize| -> f32 {
1658        f32::from_le_bytes([
1659            header.extra[offset],
1660            header.extra[offset + 1],
1661            header.extra[offset + 2],
1662            header.extra[offset + 3],
1663        ])
1664    };
1665
1666    let idtype = le_i16(64);
1667    let image_type = match idtype {
1668        0 => ImodImageType::Mono,
1669        1 => ImodImageType::Tilt,
1670        2 => ImodImageType::Tilts,
1671        3 => ImodImageType::Lina,
1672        4 => ImodImageType::Lins,
1673        _ => ImodImageType::Mono, // fallback
1674    };
1675
1676    let flags = le_i16(60) as u16; // lower 2 bytes of imodFlags
1677    let bytes_are_signed = (flags & 1) != 0;
1678    let tilt_axis = le_i16(68).clamp(1, 3) as u8;
1679    let tilt_increment = le_i16(72) as f32 / 100.0;
1680    let start_angle = le_i16(74) as f32 / 100.0;
1681
1682    // tiltangles[6] at extra[76..100], 6 little-endian f32 values
1683    let original_angles = [le_f32(76), le_f32(80), le_f32(84)];
1684    let current_angles = [le_f32(88), le_f32(92), le_f32(96)];
1685
1686    // IMOD origin and cell size from the beginning of extra bytes
1687    let x_origin = le_f32(0);
1688    let y_origin = le_f32(4);
1689    let z_origin = le_f32(8);
1690    let x_cell_size = le_f32(12);
1691    let y_cell_size = le_f32(16);
1692    let z_cell_size = le_f32(20);
1693
1694    Some(ImodMetadata {
1695        bytes_are_signed,
1696        imod_flags: flags,
1697        image_type,
1698        tilt_axis,
1699        tilt_increment,
1700        start_angle,
1701        original_angles,
1702        current_angles,
1703        x_origin,
1704        y_origin,
1705        z_origin,
1706        x_cell_size,
1707        y_cell_size,
1708        z_cell_size,
1709    })
1710}
1711
1712/// Builder for constructing validated MRC headers.
1713///
1714/// # Example
1715/// ```
1716/// use mrc::HeaderBuilder;
1717///
1718/// let header = HeaderBuilder::new()
1719///     .shape([512, 512, 256])
1720///     .mode::<f32>()
1721///     .build()
1722///     .unwrap();
1723/// ```
1724#[derive(Debug, Clone)]
1725pub struct HeaderBuilder {
1726    header: Header,
1727}
1728
1729impl HeaderBuilder {
1730    /// Create a new header builder with sensible defaults.
1731    ///
1732    /// # Examples
1733    ///
1734    /// ```
1735    /// use mrc::HeaderBuilder;
1736    /// let builder = HeaderBuilder::new();
1737    /// ```
1738    #[must_use]
1739    pub fn new() -> Self {
1740        Self {
1741            header: Header::new(),
1742        }
1743    }
1744
1745    /// Set the volume dimensions.
1746    ///
1747    /// Also synchronises `mx`, `my`, `mz` to match `nx`, `ny`, `nz`, following
1748    /// the convention used by the reference Python `mrcfile` library.
1749    ///
1750    /// # Examples
1751    ///
1752    /// ```
1753    /// use mrc::HeaderBuilder;
1754    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1755    /// let h = HeaderBuilder::new()
1756    ///     .shape([512, 512, 256])
1757    ///     .mode::<f32>()
1758    ///     .build()?;
1759    /// assert_eq!(h.nx, 512);
1760    /// # Ok(())
1761    /// # }
1762    /// ```
1763    #[must_use]
1764    pub fn shape(mut self, shape: [usize; 3]) -> Self {
1765        self.header.nx = shape[0] as i32;
1766        self.header.ny = shape[1] as i32;
1767        self.header.nz = shape[2] as i32;
1768        self.header.mx = self.header.nx;
1769        self.header.my = self.header.ny;
1770        self.header.mz = self.header.nz;
1771        self
1772    }
1773
1774    /// Set the voxel mode from a Rust type.
1775    ///
1776    /// # Examples
1777    ///
1778    /// ```
1779    /// use mrc::HeaderBuilder;
1780    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1781    /// let h = HeaderBuilder::new()
1782    ///     .shape([64, 64, 1])
1783    ///     .mode::<f32>()
1784    ///     .build()?;
1785    /// assert_eq!(h.mode, 2);
1786    /// # Ok(())
1787    /// # }
1788    /// ```
1789    #[must_use]
1790    pub fn mode<T: crate::mode::Voxel>(mut self) -> Self {
1791        self.header.mode = T::MODE.as_i32();
1792        self
1793    }
1794
1795    /// Set the MRC mode by raw integer value (for modes without a [`crate::Voxel`] impl).
1796    ///
1797    /// This is primarily useful for [`Mode::Packed4Bit`] (mode 101) which does not
1798    /// implement `Voxel`.  Invalid mode constants are caught by header validation
1799    /// at `build()` time.
1800    ///
1801    /// # Examples
1802    ///
1803    /// ```
1804    /// use mrc::HeaderBuilder;
1805    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1806    /// let h = HeaderBuilder::new()
1807    ///     .shape([8, 8, 1])
1808    ///     .mode_raw(101)
1809    ///     .build()?;
1810    /// assert_eq!(h.mode, 101);
1811    /// # Ok(())
1812    /// # }
1813    /// ```
1814    #[must_use]
1815    pub fn mode_raw(mut self, mode: i32) -> Self {
1816        self.header.mode = mode;
1817        self
1818    }
1819
1820    /// Set the cell dimensions in Angstroms.
1821    ///
1822    /// # Examples
1823    ///
1824    /// ```
1825    /// use mrc::HeaderBuilder;
1826    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1827    /// let h = HeaderBuilder::new()
1828    ///     .shape([64, 64, 1])
1829    ///     .mode::<f32>()
1830    ///     .cell_lengths(100.0, 100.0, 100.0)
1831    ///     .build()?;
1832    /// assert_eq!(h.xlen, 100.0);
1833    /// # Ok(())
1834    /// # }
1835    /// ```
1836    #[must_use]
1837    pub fn cell_lengths(mut self, xlen: f32, ylen: f32, zlen: f32) -> Self {
1838        self.header.xlen = xlen;
1839        self.header.ylen = ylen;
1840        self.header.zlen = zlen;
1841        self
1842    }
1843
1844    /// Set the cell angles in degrees (alpha, beta, gamma).
1845    ///
1846    /// # Examples
1847    ///
1848    /// ```
1849    /// use mrc::HeaderBuilder;
1850    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1851    /// let h = HeaderBuilder::new()
1852    ///     .shape([64, 64, 1])
1853    ///     .mode::<f32>()
1854    ///     .cell_angles(90.0, 90.0, 120.0)
1855    ///     .build()?;
1856    /// assert_eq!(h.gamma, 120.0);
1857    /// # Ok(())
1858    /// # }
1859    /// ```
1860    #[must_use]
1861    pub fn cell_angles(mut self, alpha: f32, beta: f32, gamma: f32) -> Self {
1862        self.header.alpha = alpha;
1863        self.header.beta = beta;
1864        self.header.gamma = gamma;
1865        self
1866    }
1867
1868    /// Set the space group number.
1869    ///
1870    /// # Examples
1871    ///
1872    /// ```
1873    /// use mrc::HeaderBuilder;
1874    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1875    /// let h = HeaderBuilder::new()
1876    ///     .shape([64, 64, 1])
1877    ///     .mode::<f32>()
1878    ///     .ispg(0)
1879    ///     .build()?;
1880    /// assert_eq!(h.ispg, 0);
1881    /// # Ok(())
1882    /// # }
1883    /// ```
1884    #[must_use]
1885    pub fn ispg(mut self, ispg: i32) -> Self {
1886        self.header.ispg = ispg;
1887        self
1888    }
1889
1890    /// Configure as a volume stack with the given sub-volume thickness.
1891    ///
1892    /// Shorthand for calling [`ispg(401)`](Self::ispg) and setting
1893    /// `mz` to the given value.  `nz` must be divisible by `mz` for
1894    /// the header to validate.
1895    ///
1896    /// # Examples
1897    ///
1898    /// ```
1899    /// use mrc::HeaderBuilder;
1900    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1901    /// let h = HeaderBuilder::new()
1902    ///     .shape([64, 64, 32])
1903    ///     .mode::<f32>()
1904    ///     .set_volume_stack(16)
1905    ///     .build()?;
1906    /// assert!(h.is_volume_stack());
1907    /// # Ok(())
1908    /// # }
1909    /// ```
1910    #[must_use]
1911    pub fn set_volume_stack(mut self, mz: i32) -> Self {
1912        self.header.set_volume_stack(mz);
1913        self
1914    }
1915
1916    /// Set the extended header type (4-byte ASCII identifier).
1917    ///
1918    /// # Examples
1919    ///
1920    /// ```
1921    /// use mrc::HeaderBuilder;
1922    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1923    /// let h = HeaderBuilder::new()
1924    ///     .shape([64, 64, 1])
1925    ///     .mode::<f32>()
1926    ///     .exttyp(*b"CCP4")
1927    ///     .build()?;
1928    /// assert_eq!(h.exttyp(), *b"CCP4");
1929    /// # Ok(())
1930    /// # }
1931    /// ```
1932    #[must_use]
1933    pub fn exttyp(mut self, exttyp: [u8; 4]) -> Self {
1934        self.header.set_exttyp(exttyp);
1935        self
1936    }
1937
1938    /// Set the extended header size in bytes.
1939    ///
1940    /// # Examples
1941    ///
1942    /// ```
1943    /// use mrc::HeaderBuilder;
1944    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1945    /// let h = HeaderBuilder::new()
1946    ///     .shape([64, 64, 1])
1947    ///     .mode::<f32>()
1948    ///     .nsymbt(256)
1949    ///     .build()?;
1950    /// assert_eq!(h.nsymbt, 256);
1951    /// # Ok(())
1952    /// # }
1953    /// ```
1954    #[must_use]
1955    pub fn nsymbt(mut self, nsymbt: i32) -> Self {
1956        self.header.nsymbt = nsymbt;
1957        self
1958    }
1959
1960    /// Set the origin coordinates.
1961    ///
1962    /// # Examples
1963    ///
1964    /// ```
1965    /// use mrc::HeaderBuilder;
1966    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1967    /// let h = HeaderBuilder::new()
1968    ///     .shape([64, 64, 1])
1969    ///     .mode::<f32>()
1970    ///     .origin([1.0, 2.0, 3.0])
1971    ///     .build()?;
1972    /// assert_eq!(h.origin, [1.0, 2.0, 3.0]);
1973    /// # Ok(())
1974    /// # }
1975    /// ```
1976    #[must_use]
1977    pub fn origin(mut self, origin: [f32; 3]) -> Self {
1978        self.header.origin = origin;
1979        self
1980    }
1981
1982    /// Set the sub-volume origin in pixels (`nxstart`, `nystart`, `nzstart`).
1983    ///
1984    /// This is the starting point of the image data within the unit cell.
1985    /// Commonly used in IMOD and tilt-series metadata.
1986    ///
1987    /// # Examples
1988    ///
1989    /// ```
1990    /// use mrc::HeaderBuilder;
1991    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1992    /// let h = HeaderBuilder::new()
1993    ///     .shape([64, 64, 1])
1994    ///     .mode::<f32>()
1995    ///     .nstart([10, 20, 0])
1996    ///     .build()?;
1997    /// assert_eq!(h.nxstart, 10);
1998    /// # Ok(())
1999    /// # }
2000    /// ```
2001    #[must_use]
2002    pub fn nstart(mut self, nstart: [i32; 3]) -> Self {
2003        self.header.nxstart = nstart[0];
2004        self.header.nystart = nstart[1];
2005        self.header.nzstart = nstart[2];
2006        self
2007    }
2008
2009    /// Set the sampling rates (`mx`, `my`, `mz`) independently of the volume
2010    /// dimensions.
2011    ///
2012    /// By default [`shape`](Self::shape) syncs `mx`, `my`, `mz` to `nx`, `ny`,
2013    /// `nz`.  Use this method to override them when the cell sampling differs
2014    /// from the pixel dimensions.
2015    ///
2016    /// # Examples
2017    ///
2018    /// ```
2019    /// use mrc::HeaderBuilder;
2020    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2021    /// let h = HeaderBuilder::new()
2022    ///     .shape([64, 64, 32])
2023    ///     .mode::<f32>()
2024    ///     .sampling([64, 64, 32])
2025    ///     .build()?;
2026    /// assert_eq!(h.mx, 64);
2027    /// # Ok(())
2028    /// # }
2029    /// ```
2030    #[must_use]
2031    pub fn sampling(mut self, sampling: [i32; 3]) -> Self {
2032        self.header.mx = sampling[0];
2033        self.header.my = sampling[1];
2034        self.header.mz = sampling[2];
2035        self
2036    }
2037
2038    /// Set the axis mapping (`mapc`, `mapr`, `maps`) — a permutation of
2039    /// `1` (X), `2` (Y), `3` (Z) that defines which axis is column, row,
2040    /// and section.
2041    ///
2042    /// The default is `[1, 2, 3]` (X=column, Y=row, Z=section), which
2043    /// covers nearly all MRC files.  Override only when you need a
2044    /// non-standard axis layout.
2045    ///
2046    /// # Examples
2047    ///
2048    /// ```
2049    /// use mrc::HeaderBuilder;
2050    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2051    /// let h = HeaderBuilder::new()
2052    ///     .shape([64, 64, 32])
2053    ///     .mode::<f32>()
2054    ///     .axis_mapping([2, 1, 3])
2055    ///     .build()?;
2056    /// assert_eq!(h.mapc, 2);
2057    /// # Ok(())
2058    /// # }
2059    /// ```
2060    #[must_use]
2061    pub fn axis_mapping(mut self, mapping: [i32; 3]) -> Self {
2062        self.header.mapc = mapping[0];
2063        self.header.mapr = mapping[1];
2064        self.header.maps = mapping[2];
2065        self
2066    }
2067
2068    /// Append a text label.
2069    ///
2070    /// Delegates to [`Header::add_label`].  Labels are stored in the
2071    /// 800-byte label field (up to 10 labels).  When full, the oldest
2072    /// label is dropped (FIFO).
2073    ///
2074    /// # Examples
2075    ///
2076    /// ```
2077    /// use mrc::HeaderBuilder;
2078    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2079    /// let h = HeaderBuilder::new()
2080    ///     .shape([64, 64, 1])
2081    ///     .mode::<f32>()
2082    ///     .add_label("my sample")
2083    ///     .build()?;
2084    /// assert_eq!(h.get_labels(), vec!["my sample"]);
2085    /// # Ok(())
2086    /// # }
2087    /// ```
2088    #[must_use]
2089    pub fn add_label(mut self, text: &str) -> Self {
2090        self.header.add_label(text);
2091        self
2092    }
2093
2094    /// Consume the builder and return the header.
2095    ///
2096    /// # Examples
2097    ///
2098    /// ```
2099    /// use mrc::HeaderBuilder;
2100    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2101    /// let h = HeaderBuilder::new()
2102    ///     .shape([64, 64, 1])
2103    ///     .mode::<f32>()
2104    ///     .build()?;
2105    /// assert!(h.validate());
2106    /// # Ok(())
2107    /// # }
2108    /// ```
2109    pub fn build(self) -> Result<Header, crate::HeaderValidationError> {
2110        self.header.validate_detailed()?;
2111        Ok(self.header)
2112    }
2113}
2114
2115impl Default for HeaderBuilder {
2116    fn default() -> Self {
2117        Self::new()
2118    }
2119}
2120
2121#[cfg(test)]
2122mod tests {
2123    use super::*;
2124
2125    /// Build a valid little-endian header encoded as raw bytes.
2126    fn le_header_bytes() -> [u8; 1024] {
2127        let mut h = Header::new();
2128        h.nx = 64;
2129        h.ny = 64;
2130        h.nz = 64;
2131        h.mx = 64;
2132        h.my = 64;
2133        h.mz = 64;
2134        h.mode = 2; // Float32
2135        h.set_file_endian(crate::FileEndian::LittleEndian);
2136        let mut bytes = [0u8; 1024];
2137        h.encode_to_bytes(&mut bytes);
2138        bytes
2139    }
2140
2141    /// Build a valid big-endian header encoded as raw bytes.
2142    fn be_header_bytes() -> [u8; 1024] {
2143        let mut h = Header::new();
2144        h.nx = 64;
2145        h.ny = 64;
2146        h.nz = 64;
2147        h.mx = 64;
2148        h.my = 64;
2149        h.mz = 64;
2150        h.mode = 2; // Float32
2151        h.set_file_endian(crate::FileEndian::BigEndian);
2152        let mut bytes = [0u8; 1024];
2153        h.encode_to_bytes(&mut bytes);
2154        bytes
2155    }
2156
2157    #[test]
2158    fn test_decode_roundtrip_le() {
2159        let original = le_header_bytes();
2160        let decoded = Header::decode_from_bytes(&original);
2161        assert_eq!(decoded.nx, 64);
2162        assert_eq!(decoded.mode, 2);
2163        assert_eq!(decoded.detect_endian(), crate::FileEndian::LittleEndian);
2164    }
2165
2166    #[test]
2167    fn test_decode_roundtrip_be() {
2168        let original = be_header_bytes();
2169        let decoded = Header::decode_from_bytes(&original);
2170        assert_eq!(decoded.nx, 64);
2171        assert_eq!(decoded.mode, 2);
2172        assert_eq!(decoded.detect_endian(), crate::FileEndian::BigEndian);
2173    }
2174
2175    #[test]
2176    fn test_byte_order_fallback_le_stamp_be_data() {
2177        // Create a file that claims to be LE (0x44 0x44) but is actually BE-encoded.
2178        let mut bytes = be_header_bytes();
2179        // Overwrite MACHST to claim LE
2180        bytes[212] = 0x44;
2181        bytes[213] = 0x44;
2182        bytes[214] = 0x00;
2183        bytes[215] = 0x00;
2184
2185        let (decoded, warning) = Header::decode_from_bytes_with_info(&bytes);
2186
2187        // Without fallback, nx would be 0x4000_0000 (garbage under LE interpretation)
2188        // With fallback, nx should correctly decode as 64.
2189        assert_eq!(
2190            decoded.nx, 64,
2191            "byte-order fallback should have corrected nx"
2192        );
2193        assert_eq!(decoded.mode, 2);
2194        assert!(
2195            warning.is_some(),
2196            "should emit a warning when MACHST mismatches actual byte order"
2197        );
2198    }
2199
2200    #[test]
2201    fn test_byte_order_fallback_be_stamp_le_data() {
2202        // Create a file that claims to be BE (0x11 0x11) but is actually LE-encoded.
2203        let mut bytes = le_header_bytes();
2204        // Overwrite MACHST to claim BE
2205        bytes[212] = 0x11;
2206        bytes[213] = 0x11;
2207        bytes[214] = 0x00;
2208        bytes[215] = 0x00;
2209
2210        let (decoded, warning) = Header::decode_from_bytes_with_info(&bytes);
2211
2212        assert_eq!(
2213            decoded.nx, 64,
2214            "byte-order fallback should have corrected nx"
2215        );
2216        assert_eq!(decoded.mode, 2);
2217        assert!(warning.is_some());
2218    }
2219
2220    #[test]
2221    fn test_no_fallback_when_machst_matches() {
2222        let bytes = le_header_bytes();
2223        let (decoded, warning) = Header::decode_from_bytes_with_info(&bytes);
2224        assert_eq!(decoded.nx, 64);
2225        assert!(warning.is_none(), "no warning when MACHST is correct");
2226    }
2227
2228    #[test]
2229    fn test_ccp41_machst_recognised() {
2230        let mut bytes = le_header_bytes();
2231        bytes[212] = 0x44;
2232        bytes[213] = 0x41;
2233        let decoded = Header::decode_from_bytes(&bytes);
2234        assert_eq!(decoded.nx, 64);
2235        assert_eq!(decoded.detect_endian(), crate::FileEndian::LittleEndian);
2236    }
2237
2238    #[test]
2239    fn test_nversion_le() {
2240        let mut h = Header::new();
2241        h.set_file_endian(crate::FileEndian::LittleEndian);
2242        assert_eq!(h.nversion(), 20141);
2243    }
2244
2245    #[test]
2246    fn test_nversion_be() {
2247        let mut h = Header::new();
2248        h.set_file_endian(crate::FileEndian::BigEndian);
2249        assert_eq!(h.nversion(), 20141);
2250    }
2251
2252    #[test]
2253    fn test_nversion_zero_accepted_by_validate() {
2254        // EPU files often leave NVERSION at 0 (uninitialized).
2255        let mut h = Header::new();
2256        h.nx = 64;
2257        h.ny = 64;
2258        h.nz = 1;
2259        h.mx = 64;
2260        h.my = 64;
2261        h.mz = 1;
2262        h.nlabl = 0;
2263        h.set_nversion(0);
2264        assert_eq!(h.nversion(), 0);
2265        assert!(h.validate(), "NVERSION=0 should pass strict validation");
2266    }
2267}