Skip to main content

atfits_rs/
mem_header.rs

1//! Build a cube's FITS header without touching disk, so the data unit is never
2//! zero-filled by cfitsio.
3//!
4//! cfitsio writes a full pass of zeros over the data unit when an image HDU is
5//! flushed/closed (≈0.5 s for a 500 MB cube; it is NOT sparse on APFS). A
6//! streaming-write tool can sidestep this the way the Python reference tools do:
7//! write only the header, sparsely extend the file to its final length (so the
8//! OS backs the data unit with zero pages on demand), then write the real planes
9//! exactly once with plain `std::fs` I/O.
10//!
11//! To get the header bytes without any disk zero-fill, [`create_mem_cube`]
12//! assembles the HDU in an **in-memory** cfitsio file (`mem://`) and
13//! [`extract_header_layout`] serialises it with `ffhdr2str`. The caller owns the
14//! data-unit write (raw `write_all_at` of big-endian planes).
15use std::ffi::{CStr, CString};
16use std::os::raw::{c_char, c_int, c_void};
17use std::path::Path;
18use std::ptr;
19
20use fitsio::FitsFile;
21use fitsio::errors::check_status;
22use fitsio::sys::{LONGLONG, fitsfile};
23
24use crate::error::{AtfitsError, Result};
25use crate::image::is_structural_keyword;
26
27/// The byte layout of a freshly built cube: the complete primary header padded
28/// to a 2880-byte block, and the byte offset where the data unit begins.
29pub struct CubeLayout {
30    /// Full primary header, padded with spaces to `datastart` bytes.
31    pub header: Vec<u8>,
32    /// Byte offset of the data unit (== `header.len()`).
33    pub datastart: u64,
34}
35
36/// Create an in-memory image HDU with the same number of axes as `dims` (FITS
37/// order, NAXIS1 first) and `bitpix`, copy every non-structural card from
38/// `template`'s primary header, and return the open (memory-backed) handle.
39///
40/// Only the axis *count* of `dims` is used here, not the lengths: the HDU is
41/// created with size-1 dummy axes so cfitsio never allocates the full
42/// (cube-sized) data unit in RAM just to assemble a header. The real axis
43/// lengths are stamped into the serialised header by [`extract_header_layout`],
44/// which takes the same `dims`. `datastart` is independent of the values (every
45/// FITS card is a fixed 80 bytes), so the layout is identical to a full-size HDU.
46///
47/// Nothing is written to disk; closing the returned handle just frees RAM. Pair
48/// with [`extract_header_layout`] to obtain the on-disk header bytes.
49pub fn create_mem_cube(template: &Path, bitpix: i64, dims: &[usize]) -> Result<FitsFile> {
50    let mut status = 0;
51    let memname = CString::new("mem://").expect("static name has no NUL");
52    let mut raw: *mut fitsfile = ptr::null_mut();
53    unsafe {
54        fitsio::sys::ffinit(&mut raw, memname.as_ptr(), &mut status);
55    }
56    check_status(status)?;
57
58    // `ffcrimll` takes naxes in FITS order (naxes[0] == NAXIS1). Use size-1 dummy
59    // axes: a full-size image would make cfitsio allocate (and zero) the entire
60    // data unit in memory. `extract_header_layout` patches the real lengths into
61    // the NAXISn cards afterwards.
62    let mut naxes: Vec<LONGLONG> = vec![1; dims.len()];
63    unsafe {
64        fitsio::sys::ffcrimll(
65            raw,
66            bitpix as c_int,
67            naxes.len() as c_int,
68            naxes.as_mut_ptr(),
69            &mut status,
70        );
71    }
72    if let Err(e) = check_status(status) {
73        let mut close = 0;
74        unsafe { fitsio::sys::ffclos(raw, &mut close) };
75        return Err(e.into());
76    }
77
78    // Copy every non-structural card from the template's primary header.
79    let mut in_fptr = FitsFile::open(template.to_string_lossy().as_ref())?;
80    in_fptr.primary_hdu()?;
81    unsafe {
82        let mut nkeys: c_int = 0;
83        let mut morekeys: c_int = 0;
84        fitsio::sys::ffghsp(in_fptr.as_raw(), &mut nkeys, &mut morekeys, &mut status);
85        check_status(status)?;
86
87        // `c_char` signedness differs by platform; match cfitsio's `*mut c_char`.
88        let mut card = [0 as c_char; 81];
89        for i in 1..=nkeys {
90            card.fill(0);
91            fitsio::sys::ffgrec(in_fptr.as_raw(), i, card.as_mut_ptr(), &mut status);
92            if check_status(status).is_err() {
93                break;
94            }
95            let card_str = CStr::from_ptr(card.as_ptr()).to_string_lossy();
96            let name = card_str.split([' ', '=']).next().unwrap_or("").trim();
97            if is_structural_keyword(name) {
98                continue;
99            }
100            fitsio::sys::ffprec(raw, card.as_ptr(), &mut status);
101            check_status(status)?;
102        }
103    }
104
105    let handle = unsafe { FitsFile::from_raw(raw, fitsio::FileOpenMode::READWRITE)? };
106    Ok(handle)
107}
108
109/// Overwrite the value of the `NAXIS{axis}` card in a serialised FITS header (a
110/// run of 80-byte cards) with `value`, preserving the 80-byte card width and any
111/// trailing comment. The HDU is built with size-1 dummy axes (see
112/// [`create_mem_cube`]); this stamps the real lengths into the header bytes
113/// without asking cfitsio to resize the data unit.
114fn set_naxis_card(header: &mut [u8], axis: usize, value: i64) -> Result<()> {
115    let key = format!("NAXIS{axis}");
116    let key_bytes = key.as_bytes();
117    for card in header.chunks_exact_mut(80) {
118        // The keyword occupies bytes 0..8, blank-padded.
119        let kw_end = card[..8].iter().position(|&b| b == b' ').unwrap_or(8);
120        if kw_end == key_bytes.len() && &card[..kw_end] == key_bytes {
121            // Fixed-format integers are right-justified in columns 11..30 (byte
122            // indices 10..30); bytes 30..80 hold any comment.
123            let s = value.to_string();
124            if s.len() > 20 {
125                return Err(AtfitsError::Other(format!(
126                    "NAXIS{axis} value {value} does not fit a FITS card"
127                )));
128            }
129            let field = &mut card[10..30];
130            field.fill(b' ');
131            field[20 - s.len()..].copy_from_slice(s.as_bytes());
132            return Ok(());
133        }
134    }
135    Err(AtfitsError::Other(format!(
136        "NAXIS{axis} card not found in serialised header"
137    )))
138}
139
140/// Serialise the primary header of `fptr` to bytes and report where the data
141/// unit starts, so the caller can lay the header down with raw I/O.
142///
143/// `dims` are the real axis lengths (FITS order, NAXIS1 first). They are stamped
144/// into the `NAXISn` cards because [`create_mem_cube`] builds the HDU with size-1
145/// dummy axes to avoid allocating a cube-sized data unit; pass the same `dims`
146/// given to `create_mem_cube`.
147pub fn extract_header_layout(fptr: &mut FitsFile, dims: &[usize]) -> Result<CubeLayout> {
148    fptr.primary_hdu()?; // position at the primary HDU
149    let raw = unsafe { fptr.as_raw() };
150    let mut status = 0;
151
152    let mut headstart: LONGLONG = 0;
153    let mut datastart: LONGLONG = 0;
154    let mut dataend: LONGLONG = 0;
155    unsafe {
156        fitsio::sys::ffghadll(
157            raw,
158            &mut headstart,
159            &mut datastart,
160            &mut dataend,
161            &mut status,
162        );
163    }
164    check_status(status)?;
165
166    // `ffhdr2str` concatenates every card (80 chars each) into one malloc'd
167    // string, EXCLUDING the END card and the trailing block padding.
168    let mut header_ptr: *mut c_char = ptr::null_mut();
169    let mut nkeys: c_int = 0;
170    unsafe {
171        fitsio::sys::ffhdr2str(
172            raw,
173            0, // keep COMMENT/HISTORY cards
174            ptr::null_mut(),
175            0,
176            &mut header_ptr,
177            &mut nkeys,
178            &mut status,
179        );
180    }
181    check_status(status)?;
182
183    let mut header = unsafe {
184        let bytes = CStr::from_ptr(header_ptr).to_bytes().to_vec();
185        let mut free_status = 0;
186        fitsio::sys::fffree(header_ptr as *mut c_void, &mut free_status);
187        bytes
188    };
189
190    let datastart = datastart as u64;
191    if header.len() as u64 + 3 > datastart {
192        return Err(AtfitsError::Other(format!(
193            "header ({} bytes) does not fit before data unit ({datastart} bytes)",
194            header.len()
195        )));
196    }
197    // Re-append the END card and pad to the data-unit boundary with spaces — the
198    // exact on-disk header cfitsio's own layout (datastart) accounts for.
199    header.extend_from_slice(b"END");
200    header.resize(datastart as usize, b' ');
201
202    // The HDU was built with size-1 dummy axes (see `create_mem_cube`); stamp the
203    // real lengths into the NAXISn cards. Card width is fixed, so `datastart` is
204    // unaffected.
205    for (i, &d) in dims.iter().enumerate() {
206        set_naxis_card(&mut header, i + 1, d as i64)?;
207    }
208
209    Ok(CubeLayout { header, datastart })
210}