Skip to main content

fits_io/fs/
fs_fits.rs

1use crate::fits::Fits;
2use crate::fs::fs_ascii_table_hdu::FsAsciiTableHDU;
3use crate::fs::fs_bin_table_hdu::FsBinTableHDU;
4use crate::fs::fs_image_hdu::FsImageHDU;
5use crate::fs::is_fits_file;
6use crate::fs::open_fits_file::open_fits_file;
7use crate::hdu::{ExtensionHDU, HDU};
8use crate::header::header::BLOCK_NUM_BYTES;
9use crate::header::{ExtensionType, Header};
10use log::{debug, info};
11use std::error::Error;
12use std::fs;
13use std::io::Seek;
14use std::path::{Path, PathBuf};
15
16/// A FITS file read from the filesystem.
17#[derive(Debug, Clone)]
18pub struct FsFits {
19    path: PathBuf,
20    primary_hdu: FsImageHDU,
21    extension_hdus: Vec<ExtensionHDU<Self>>,
22}
23
24impl FsFits {
25    /// Opens a new fits file
26    pub fn open(path: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
27        Self::assert_file_type(path)?;
28        debug!("Opening FITS file: {:?}", path);
29        let mut reader = open_fits_file(path)?;
30
31        let header =
32            Header::from_reader(&mut reader)?.ok_or("Could not read primary FITS header")?;
33        debug!("Opened primary header: {:?}", header);
34        header.validate_primary()?;
35
36        let primary_hdu = FsImageHDU::new_primary(path, header);
37
38        let mut extension_hdus = vec![];
39        let mut offset = primary_hdu.byte_size();
40
41        loop {
42            reader.seek(std::io::SeekFrom::Start(offset))?;
43
44            if let Some(header) = Header::from_reader(&mut reader)? {
45                header.validate_extension()?;
46                debug!("Found extension header: {:?}", header);
47
48                let extension_type = header.extension().ok_or(
49                    "This is not a valid fits extension. Card XTENSION is missing or invalid",
50                )?;
51
52                match extension_type {
53                    ExtensionType::Image => {
54                        let extension_hdu = FsImageHDU::new_extension(path, header, offset)?;
55                        offset += extension_hdu.byte_size();
56                        extension_hdus.push(ExtensionHDU::Image(extension_hdu));
57                    }
58                    // A compressed image is stored as a table, but it is an
59                    // image, and presenting it as a table of opaque bytes would
60                    // leave every caller to notice and unpack it themselves.
61                    ExtensionType::BinTable if header.is_compressed_image() => {
62                        let extension_hdu = FsImageHDU::new_extension(path, header, offset)?;
63                        offset += extension_hdu.byte_size();
64                        extension_hdus.push(ExtensionHDU::Image(extension_hdu));
65                    }
66                    ExtensionType::BinTable => {
67                        let extension_hdu = FsBinTableHDU::new(path, header, offset)?;
68                        offset += extension_hdu.byte_size();
69                        extension_hdus.push(ExtensionHDU::BinTable(extension_hdu));
70                    }
71                    ExtensionType::AsciiTable => {
72                        let extension_hdu = FsAsciiTableHDU::new(path, header, offset)?;
73                        offset += extension_hdu.byte_size();
74                        extension_hdus.push(ExtensionHDU::AsciiTable(extension_hdu));
75                    }
76                }
77            } else {
78                break;
79            }
80        }
81        info!("Opened FITS file: {:?}", path);
82        Ok(Self {
83            path: path.to_path_buf(),
84            primary_hdu,
85            extension_hdus,
86        })
87    }
88
89    /// Opens a file asynchronously, this avoids blocking the tokio runtime
90    #[cfg(feature = "tokio")]
91    pub async fn open_async(path: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
92        let path = path.to_path_buf();
93        tokio::task::spawn_blocking(move || Self::open(&path)).await?
94    }
95
96    /// An empty FITS file that will be written to `path`.
97    pub fn new(path: &Path) -> Self {
98        Self {
99            path: path.to_path_buf(),
100            primary_hdu: FsImageHDU::new_primary(path, Header::default()),
101            extension_hdus: vec![],
102        }
103    }
104
105    /// Writes this FITS file back to [`FsFits::path`].
106    ///
107    /// The file is written to a temporary file beside it and then renamed, so a
108    /// failure part way through leaves the original where it was rather than
109    /// truncated.
110    pub fn save(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
111        self.save_as(&self.path)
112    }
113
114    /// Writes this FITS file to `path`.
115    pub fn save_as(&self, path: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
116        let bytes = self.to_vec()?;
117
118        let temporary = path.with_extension(format!(
119            "{}.fits-io-tmp",
120            path.extension().unwrap_or_default().to_string_lossy()
121        ));
122
123        fs::write(&temporary, &bytes)?;
124        if let Err(error) = fs::rename(&temporary, path) {
125            // Leaving the half-written file behind would be worse than the
126            // failure itself.
127            let _ = fs::remove_file(&temporary);
128            return Err(error.into());
129        }
130
131        info!("Wrote FITS file: {:?}", path);
132
133        Ok(())
134    }
135
136    /// Retrieves the path this FITS file belongs to
137    pub fn path(&self) -> &Path {
138        &self.path
139    }
140
141    fn assert_file_type(path: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
142        if is_fits_file(path) {
143            Ok(())
144        } else {
145            Err("Invalid file type".into())
146        }
147    }
148}
149
150impl Fits for FsFits {
151    type ImageHDU = FsImageHDU;
152    type BinTableHDU = FsBinTableHDU;
153    type AsciiTableHDU = FsAsciiTableHDU;
154
155    fn primary_hdu(&self) -> &Self::ImageHDU {
156        &self.primary_hdu
157    }
158
159    fn primary_hdu_mut(&mut self) -> &mut Self::ImageHDU {
160        &mut self.primary_hdu
161    }
162
163    fn extension_count(&self) -> usize {
164        self.extension_hdus.len()
165    }
166
167    fn extension_hdu(&self, index: usize) -> Option<&ExtensionHDU<Self>> {
168        self.extension_hdus.get(index)
169    }
170
171    fn extension_hdu_mut(&mut self, index: usize) -> Option<&mut ExtensionHDU<Self>> {
172        self.extension_hdus.get_mut(index)
173    }
174
175    fn extension_hdus(&self) -> impl Iterator<Item = &ExtensionHDU<Self>> {
176        self.extension_hdus.iter()
177    }
178
179    fn extension_hdus_mut(&mut self) -> impl Iterator<Item = &mut ExtensionHDU<Self>> {
180        self.extension_hdus.iter_mut()
181    }
182
183    fn push_extension(&mut self, extension: ExtensionHDU<Self>) {
184        self.extension_hdus.push(extension);
185    }
186
187    fn remove_extension(&mut self, index: usize) -> Option<ExtensionHDU<Self>> {
188        (index < self.extension_hdus.len()).then(|| self.extension_hdus.remove(index))
189    }
190
191    /// Serialises this file, primary HDU first and then every extension.
192    ///
193    /// Each HDU contributes its header followed by its data section, both padded
194    /// out to whole 2880-byte blocks.
195    fn to_vec(&self) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
196        let mut bytes = Vec::new();
197
198        // A compressed image lives in a binary table, and the primary HDU of a
199        // FITS file cannot be one. Rather than write a file no reader would
200        // accept, the image goes into the first extension and the primary HDU
201        // is left empty — which is what `fpack` does with the same problem, and
202        // is what this crate reads back as the image it was.
203        if self.primary_hdu.header().is_compressed_image() {
204            append_hdu(&mut bytes, &empty_primary_header(), &[], DataPadding::Zero)?;
205            append_hdu(
206                &mut bytes,
207                &self
208                    .primary_hdu
209                    .header()
210                    .conformed(Some(ExtensionType::BinTable)),
211                &self.primary_hdu.data_bytes()?,
212                DataPadding::Zero,
213            )?;
214        } else {
215            append_hdu(
216                &mut bytes,
217                &self.primary_hdu.header().conformed(None),
218                &self.primary_hdu.data_bytes()?,
219                DataPadding::Zero,
220            )?;
221        }
222
223        for extension in &self.extension_hdus {
224            let (header, data, padding) = match extension {
225                // A compressed image is written as the table it is stored
226                // as; XTENSION says how to read the bytes, not what they mean.
227                ExtensionHDU::Image(hdu) => (
228                    hdu.header()
229                        .conformed(Some(extension_type_of(hdu.header()))),
230                    hdu.data_bytes()?,
231                    DataPadding::Zero,
232                ),
233                ExtensionHDU::BinTable(hdu) => (
234                    hdu.header().conformed(Some(ExtensionType::BinTable)),
235                    hdu.data_bytes()?,
236                    DataPadding::Zero,
237                ),
238                // An ASCII table holds characters, and the standard pads it with
239                // the blanks that a character field means, not with zero bytes.
240                ExtensionHDU::AsciiTable(hdu) => (
241                    hdu.header().conformed(Some(ExtensionType::AsciiTable)),
242                    hdu.data_bytes()?,
243                    DataPadding::Blank,
244                ),
245            };
246
247            append_hdu(&mut bytes, &header, &data, padding)?;
248        }
249
250        Ok(bytes)
251    }
252}
253
254/// What a data section is padded out to its block boundary with.
255#[derive(Debug, Clone, Copy)]
256enum DataPadding {
257    Zero,
258    Blank,
259}
260
261impl DataPadding {
262    fn byte(self) -> u8 {
263        match self {
264            DataPadding::Zero => 0,
265            DataPadding::Blank => b' ',
266        }
267    }
268}
269
270/// Appends one HDU: its header, its data, and the padding that squares the data
271/// off to a whole number of blocks.
272///
273/// The header is rendered last, because its CHECKSUM card covers the padded data
274/// as well as the header itself.
275fn append_hdu(
276    bytes: &mut Vec<u8>,
277    header: &Header,
278    data: &[u8],
279    padding: DataPadding,
280) -> Result<(), Box<dyn Error + Send + Sync>> {
281    header.validate_against_data(data.len())?;
282
283    let mut data = data.to_vec();
284    let overhang = data.len() % BLOCK_NUM_BYTES;
285    if overhang != 0 {
286        data.resize(data.len() + BLOCK_NUM_BYTES - overhang, padding.byte());
287    }
288
289    bytes.extend_from_slice(&header.checksummed_bytes(&data));
290    bytes.extend_from_slice(&data);
291
292    Ok(())
293}
294
295/// Which kind of extension an image HDU is written as.
296///
297/// A tile-compressed image lives in a binary table, and a reader finds it by its
298/// XTENSION before it ever looks at the `Z` keywords that say it is an image.
299fn extension_type_of(header: &Header) -> ExtensionType {
300    if header.is_compressed_image() {
301        ExtensionType::BinTable
302    } else {
303        ExtensionType::Image
304    }
305}
306
307/// The header of a primary HDU holding no data, for a file whose image had to
308/// move into an extension.
309fn empty_primary_header() -> Header {
310    let mut header = Header::default();
311
312    // Without EXTEND, a reader is entitled to stop at the primary HDU and never
313    // look for the extension the image is in.
314    let _ = header.set_card(crate::header::card_keys::EXTEND, true);
315
316    header.conformed(None)
317}