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}