Skip to main content

document_svg/document/
fits.rs

1//! Bounded FITS astronomical image preview.
2//!
3//! Reads the primary image HDU of a FITS file, validates the fixed 2880-byte
4//! header and common BITPIX/NAXIS cards, and renders each image plane as a
5//! grayscale PNG-backed SVG page. Tables, extensions, WCS, provenance and
6//! instrument metadata remain inert and are reported as omitted.
7
8use std::io::{Cursor, Read};
9use std::path::Path;
10
11use base64::Engine;
12use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
13use flate2::read::GzDecoder;
14use png::Encoder;
15
16use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
17use crate::document::html::{HtmlBlock, render_blocks_to_pages_with_warnings};
18use crate::error::{Error, Result};
19
20const MAX_FITS_INPUT_BYTES: u64 = 128 * 1024 * 1024;
21const MAX_FITS_DECOMPRESSED_BYTES: usize = 256 * 1024 * 1024;
22const MAX_FITS_DIMENSION: usize = 8192;
23const MAX_FITS_PIXELS: u64 = 300_000_000;
24const MAX_FITS_PLANES: usize = 1_000;
25const MAX_FITS_SLICE_PNG_BYTES: usize = 16 * 1024 * 1024;
26const MAX_FITS_TOTAL_URI_BYTES: usize = 512 * 1024 * 1024;
27
28pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
29    bytes.len() >= 80 && bytes[0..8] == *b"SIMPLE  "
30}
31
32pub(crate) fn convert(
33    path: &Path,
34    options: &ConvertOptions,
35    sink: &mut dyn PageConsumer,
36) -> Result<Vec<String>> {
37    let compressed = read_limited_file(
38        path,
39        options.max_input_bytes.min(MAX_FITS_INPUT_BYTES),
40        "FITS input",
41    )?;
42    let bytes = if path
43        .file_name()
44        .and_then(|name| name.to_str())
45        .is_some_and(|name| name.to_ascii_lowercase().ends_with(".fits.gz"))
46    {
47        let decoder = GzDecoder::new(Cursor::new(compressed));
48        let mut decompressed = Vec::new();
49        decoder
50            .take(MAX_FITS_DECOMPRESSED_BYTES as u64 + 1)
51            .read_to_end(&mut decompressed)?;
52        if decompressed.len() > MAX_FITS_DECOMPRESSED_BYTES {
53            return Err(Error::LimitExceeded(format!(
54                "FITS gzip stream exceeds {MAX_FITS_DECOMPRESSED_BYTES} decompressed bytes"
55            )));
56        }
57        decompressed
58    } else {
59        compressed
60    };
61    let (blocks, warnings) = parse_primary_image(&bytes, options.max_pages)?;
62    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
63    Ok(warnings)
64}
65
66fn parse_primary_image(bytes: &[u8], max_pages: usize) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
67    if bytes.len() < 2880 || bytes[0..8] != *b"SIMPLE  " {
68        return Err(Error::InvalidInput(
69            "FITS input is missing a SIMPLE primary header".into(),
70        ));
71    }
72    let header_end = find_header_end(bytes)?;
73    let header = &bytes[..header_end];
74    let bitpix = parse_card_i64(header, "BITPIX")?;
75    let naxis = parse_card_i64(header, "NAXIS")?;
76    if !(1..=4).contains(&naxis) {
77        return Err(Error::Unsupported(format!(
78            "FITS NAXIS {naxis} is unsupported; expected 1 through 4"
79        )));
80    }
81    let mut dimensions = [1usize; 4];
82    for (index, dimension) in dimensions.iter_mut().enumerate().take(naxis as usize) {
83        let value = parse_card_i64(header, &format!("NAXIS{}", index + 1))?;
84        if value <= 0 || value as usize > MAX_FITS_DIMENSION {
85            return Err(Error::LimitExceeded(format!(
86                "FITS NAXIS{} exceeds {MAX_FITS_DIMENSION}",
87                index + 1
88            )));
89        }
90        *dimension = value as usize;
91    }
92    let planes = dimensions[2].saturating_mul(dimensions[3]).max(1);
93    if planes > MAX_FITS_PLANES || planes > max_pages {
94        return Err(Error::LimitExceeded(format!(
95            "FITS contains {planes} image planes but max_pages is {max_pages}"
96        )));
97    }
98    let pixels = u64::try_from(dimensions[0])
99        .unwrap_or(u64::MAX)
100        .saturating_mul(u64::try_from(dimensions[1]).unwrap_or(u64::MAX))
101        .saturating_mul(u64::try_from(planes).unwrap_or(u64::MAX));
102    if pixels > MAX_FITS_PIXELS {
103        return Err(Error::LimitExceeded(format!(
104            "FITS image pixels exceed {MAX_FITS_PIXELS}"
105        )));
106    }
107    let bytes_per_value = match bitpix {
108        8 => 1,
109        16 => 2,
110        32 | -32 => 4,
111        64 | -64 => 8,
112        _ => {
113            return Err(Error::Unsupported(format!(
114                "FITS BITPIX {bitpix} is unsupported"
115            )));
116        }
117    };
118    let total_data = usize::try_from(pixels)
119        .ok()
120        .and_then(|count| count.checked_mul(bytes_per_value))
121        .ok_or_else(|| Error::LimitExceeded("FITS data size overflowed".into()))?;
122    let data_offset = align_2880(header_end);
123    let data_end = data_offset
124        .checked_add(total_data)
125        .ok_or_else(|| Error::LimitExceeded("FITS data range overflowed".into()))?;
126    if data_end > bytes.len() {
127        return Err(Error::InvalidInput(
128            "FITS primary image data is truncated".into(),
129        ));
130    }
131    let bscale = parse_card_f64(header, "BSCALE").unwrap_or(1.0);
132    let bzero = parse_card_f64(header, "BZERO").unwrap_or(0.0);
133    let values = decode_values(&bytes[data_offset..data_end], bitpix, bscale, bzero)?;
134    let (min, max) = values
135        .iter()
136        .copied()
137        .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), value| {
138            (min.min(value), max.max(value))
139        });
140    let mut warnings = vec![
141        "FITS extension HDUs, tables, WCS, provenance, and instrument metadata are omitted".into(),
142        "FITS image planes are rendered as grayscale PNG images".into(),
143    ];
144    let mut blocks = Vec::new();
145    let mut total_uri_bytes = 0usize;
146    let plane_pixels = dimensions[0].saturating_mul(dimensions[1]);
147    for plane in 0..planes {
148        let start = plane.saturating_mul(plane_pixels);
149        let pixels = values[start..start + plane_pixels]
150            .iter()
151            .map(|value| scale_sample(*value, min, max))
152            .collect::<Vec<_>>();
153        let png = encode_gray_png(dimensions[0] as u32, dimensions[1] as u32, &pixels)?;
154        if png.len() > MAX_FITS_SLICE_PNG_BYTES {
155            return Err(Error::LimitExceeded(format!(
156                "FITS plane PNG exceeds {MAX_FITS_SLICE_PNG_BYTES} bytes"
157            )));
158        }
159        let href = format!("data:image/png;base64,{}", BASE64_STANDARD.encode(&png));
160        total_uri_bytes = total_uri_bytes.saturating_add(href.len());
161        if total_uri_bytes > MAX_FITS_TOTAL_URI_BYTES {
162            return Err(Error::LimitExceeded(format!(
163                "FITS image data URI bytes exceed {MAX_FITS_TOTAL_URI_BYTES}"
164            )));
165        }
166        blocks.push(HtmlBlock::Heading {
167            level: 2,
168            text: format!("FITS image plane {}", plane + 1),
169        });
170        blocks.push(HtmlBlock::Image {
171            href,
172            pixel_width: dimensions[0] as u32,
173            pixel_height: dimensions[1] as u32,
174            alt: format!("FITS grayscale plane {}", plane + 1),
175        });
176        if plane + 1 < planes {
177            blocks.push(HtmlBlock::PageBreak);
178        }
179    }
180    Ok((blocks, std::mem::take(&mut warnings)))
181}
182
183fn find_header_end(bytes: &[u8]) -> Result<usize> {
184    for block_start in (0..bytes.len()).step_by(2880) {
185        let block_end = block_start.saturating_add(2880).min(bytes.len());
186        if block_end - block_start < 2880 {
187            break;
188        }
189        for card_start in (block_start..block_end).step_by(80) {
190            let card = &bytes[card_start..card_start + 80];
191            if card.starts_with(b"END") {
192                return Ok(block_end);
193            }
194        }
195    }
196    Err(Error::InvalidInput("FITS header has no END card".into()))
197}
198
199fn align_2880(value: usize) -> usize {
200    value.div_ceil(2880) * 2880
201}
202
203fn card_value<'a>(header: &'a [u8], key: &str) -> Option<&'a str> {
204    for card in header.chunks_exact(80) {
205        let name = std::str::from_utf8(&card[..8]).ok()?.trim();
206        if name == key {
207            let text = std::str::from_utf8(&card[10..]).ok()?;
208            return text.split('/').next().map(str::trim);
209        }
210    }
211    None
212}
213
214fn parse_card_i64(header: &[u8], key: &str) -> Result<i64> {
215    card_value(header, key)
216        .ok_or_else(|| Error::InvalidInput(format!("FITS header is missing {key}")))?
217        .parse::<i64>()
218        .map_err(|_| Error::InvalidInput(format!("FITS {key} is invalid")))
219}
220
221fn parse_card_f64(header: &[u8], key: &str) -> Option<f64> {
222    card_value(header, key)?
223        .replace('D', "E")
224        .parse::<f64>()
225        .ok()
226}
227
228fn decode_values(bytes: &[u8], bitpix: i64, scale: f64, zero: f64) -> Result<Vec<f64>> {
229    let width = (bitpix.unsigned_abs() / 8) as usize;
230    if width == 0 || !bytes.len().is_multiple_of(width) {
231        return Err(Error::InvalidInput(
232            "FITS pixel data has an invalid stride".into(),
233        ));
234    }
235    let mut values = Vec::with_capacity(bytes.len() / width);
236    for chunk in bytes.chunks_exact(width) {
237        let raw = match bitpix {
238            8 => f64::from(chunk[0]),
239            16 => i16::from_be_bytes([chunk[0], chunk[1]]) as f64,
240            32 => i32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) as f64,
241            64 => i64::from_be_bytes([
242                chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
243            ]) as f64,
244            -32 => {
245                f32::from_bits(u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) as f64
246            }
247            -64 => f64::from_bits(u64::from_be_bytes([
248                chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
249            ])),
250            _ => unreachable!(),
251        };
252        values.push(raw.mul_add(scale, zero));
253    }
254    Ok(values)
255}
256
257fn scale_sample(value: f64, min: f64, max: f64) -> u8 {
258    if !value.is_finite() || !min.is_finite() || !max.is_finite() || max <= min {
259        return 0;
260    }
261    (((value - min) / (max - min)).clamp(0.0, 1.0) * 255.0).round() as u8
262}
263
264fn encode_gray_png(width: u32, height: u32, pixels: &[u8]) -> Result<Vec<u8>> {
265    let mut png = Vec::new();
266    let mut encoder = Encoder::new(&mut png, width, height);
267    encoder.set_color(png::ColorType::Grayscale);
268    encoder.set_depth(png::BitDepth::Eight);
269    let mut writer = encoder
270        .write_header()
271        .map_err(|error| Error::InvalidInput(format!("cannot encode FITS PNG: {error}")))?;
272    writer
273        .write_image_data(pixels)
274        .map_err(|error| Error::InvalidInput(format!("cannot encode FITS PNG: {error}")))?;
275    drop(writer);
276    Ok(png)
277}