Skip to main content

fits_well/hdu/
mod.rs

1//! HDU classification and the data-unit sizing formula.
2//!
3//! Boundaries are computable from the header alone — `Nbits = |BITPIX| · GCOUNT ·
4//! (PCOUNT + Π NAXISn)`, rounded up to a block — so the reader never touches data
5//! to find the next HDU.
6
7use crate::block::padded_len;
8use crate::error::FitsError;
9use crate::error::Result;
10use crate::header::Header;
11
12/// The structural kind of an HDU, inferred from its mandatory keywords.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum HduKind {
15    /// Primary array (`SIMPLE = T`), possibly empty (`NAXIS = 0`).
16    Primary,
17    /// `XTENSION = 'IMAGE'` — same data model as the primary array.
18    Image,
19    /// `XTENSION = 'TABLE'` — ASCII table.
20    AsciiTable,
21    /// `XTENSION = 'BINTABLE'` — binary table (with optional heap).
22    BinTable,
23    /// A tiled-compressed image (§10.1): structurally a `BINTABLE` with `ZIMAGE = T`.
24    /// `read_image` reads it like any other image.
25    CompressedImage,
26    /// A tiled-compressed table (§10.3): structurally a `BINTABLE` with `ZTABLE = T`.
27    /// `read_compressed_table` uncompresses it.
28    CompressedTable,
29    /// Legacy random-groups primary (`GROUPS = T`, `NAXIS1 = 0`). Read-only.
30    RandomGroups,
31    /// A conforming extension whose `XTENSION` value this crate does not model.
32    Other,
33}
34
35impl HduKind {
36    pub(crate) fn classify(header: &Header) -> HduKind {
37        // `Value::Text` already stripped the trailing spaces of `'IMAGE   '` etc.
38        if let Some(xtension) = header.get_text("XTENSION") {
39            match xtension {
40                "IMAGE" => HduKind::Image,
41                "TABLE" => HduKind::AsciiTable,
42                // §10: a tiled-compressed image/table rides inside a BINTABLE,
43                // flagged by ZIMAGE/ZTABLE — classify by the payload, not the
44                // container, so callers see what they can actually read.
45                "BINTABLE" if header.get_logical("ZIMAGE") == Some(true) => {
46                    HduKind::CompressedImage
47                }
48                "BINTABLE" if header.get_logical("ZTABLE") == Some(true) => {
49                    HduKind::CompressedTable
50                }
51                "BINTABLE" => HduKind::BinTable,
52                _ => HduKind::Other,
53            }
54        } else if header.get_logical("GROUPS") == Some(true) {
55            HduKind::RandomGroups
56        } else {
57            HduKind::Primary
58        }
59    }
60}
61
62/// The size of an HDU's data unit, both as the raw bit-count-derived byte length
63/// and rounded to the on-disk block grid.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub(crate) struct DataExtent {
66    /// Unpadded data length (`Nbits / 8`).
67    pub(crate) data_bytes: u64,
68    /// Length rounded up to the 2880-byte grid — the bytes occupied on disk.
69    pub(crate) padded_bytes: u64,
70}
71
72/// Compute the data-unit extent from a parsed header (Eq. 2).
73pub(crate) fn data_extent(header: &Header) -> Result<DataExtent> {
74    let elem = header.bitpix()?.elem_size() as u64;
75    let axes = header.axes()?;
76    // PCOUNT/GCOUNT are mandatory ≥0 / ≥1 integers; a present-but-out-of-range
77    // value is malformed and must not be silently clamped (it would yield a
78    // plausible-but-wrong extent and a bad seek). Absence keeps the primary/IMAGE
79    // defaults of 0 and 1.
80    let pcount = match header.get_integer("PCOUNT") {
81        Some(p) if p < 0 => return Err(FitsError::KeywordOutOfRange { name: "PCOUNT" }),
82        Some(p) => p as u64,
83        None => 0,
84    };
85    let gcount = match header.get_integer("GCOUNT") {
86        Some(g) if g < 1 => return Err(FitsError::KeywordOutOfRange { name: "GCOUNT" }),
87        Some(g) => g as u64,
88        None => 1,
89    };
90    let random_groups = header.get_logical("GROUPS") == Some(true);
91
92    // `NAXIS = 0` means no data array at all (the empty product would be 1, not
93    // 0). Otherwise multiply the axis lengths — skipping the leading zero sentinel
94    // for random groups. All arithmetic is checked: the axis lengths come from an
95    // untrusted file and an overflowed product would drive a wild allocation in
96    // the reader.
97    let array_elems = if axes.is_empty() {
98        0
99    } else {
100        let array_axes: &[usize] = if random_groups { &axes[1..] } else { &axes };
101        array_axes
102            .iter()
103            .try_fold(1u64, |acc, &n| acc.checked_mul(n as u64))
104            .ok_or(FitsError::DataUnitOverflow)?
105    };
106
107    let group_size = pcount
108        .checked_add(array_elems)
109        .ok_or(FitsError::DataUnitOverflow)?;
110    let data_bytes = elem
111        .checked_mul(gcount)
112        .and_then(|n| n.checked_mul(group_size))
113        .ok_or(FitsError::DataUnitOverflow)?;
114    Ok(DataExtent {
115        data_bytes,
116        padded_bytes: padded_len(data_bytes),
117    })
118}
119
120#[cfg(test)]
121mod tests;