1use 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}