Add FITS read/write (astronomy images) (#2956)

* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
David Moulton 2026-10-11 11:15:22 -06:00 committed by GitHub
parent f8af27d261
commit a35af4e279
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 735 additions and 9 deletions

View File

@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR and Radiance HDR, and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>

View File

@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
"ase", "af", "afdesign", "afphoto", "afpub",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";

View File

@ -21,8 +21,8 @@ mod recovery_tests;
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@ -60,6 +60,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("TIFF", &["tif"]),
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog

View File

@ -44,6 +44,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@ -62,6 +63,14 @@ the same.
## Documented asymmetries and limitations
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
stay exact as U8/U16 (a camera's `BITPIX = 16`, `BZERO = 32768`); the rest become F32, taken as
normalised when at most 2 and scaled by 65535 (or the maximum) otherwise. Header cards are
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.

View File

@ -0,0 +1,675 @@
//! FITS (Flexible Image Transport System, FITS Standard 4.0), implemented here: the image in the
//! primary HDU, or the first `IMAGE` extension when the primary HDU holds no data.
//!
//! Reading:
//!
//! * `BITPIX` 8, 16, 32, 64, -32 and -64, with `BSCALE`/`BZERO` and integer `BLANK`.
//! * `NAXIS3 = 3` opens as RGB (the planes are R, G, B); any other plane count opens the first
//! plane as gray with [`DecodeWarning::MorePages`].
//! * Rows are stored bottom-to-top, as the standard's convention has it (Siril, DS9, Astropy),
//! unless `ROWORDER = 'TOP-DOWN'`.
//! * Physical values that fit (unsigned 8-bit, or integers in 0..=65535 such as a camera's
//! `BITPIX = 16`, `BZERO = 32768`) are kept exactly as U8/U16. Everything else becomes F32:
//! values up to 2 are taken as already normalised (Siril, PixInsight), larger ones are scaled by
//! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN
//! and blank pixels read as 0.
//! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and
//! `HISTORY` by their text, `HIERARCH` keywords by their long name.
//!
//! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards.
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{EncodeOptions, Limits};
const F: Format = Format::Fits;
const BLOCK: usize = 2880;
const CARD: usize = 80;
/// Most HDUs walked looking for an image; FITS files from cameras and stackers hold a handful.
const MAX_HDUS: usize = 1000;
/// Keywords that describe the data layout. They are written from the image, never copied from
/// (or kept as) text metadata: after decoding they no longer describe the pixels.
const STRUCTURAL: &[&str] = &[
"SIMPLE", "BITPIX", "NAXIS", "EXTEND", "BZERO", "BSCALE", "BLANK", "END", "XTENSION", "PCOUNT", "GCOUNT", "ROWORDER", "CHECKSUM", "DATASUM", "DATAMIN",
"DATAMAX", "ZIMAGE",
];
fn err(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, msg)
}
#[derive(Debug, Clone, PartialEq)]
enum Value {
/// A quoted string, unescaped, trailing blanks removed.
Str(String),
/// Anything else (number, logical, complex) as written.
Raw(String),
}
#[derive(Debug, Clone)]
struct Card {
key: String,
/// `None` for commentary cards (`COMMENT`, `HISTORY`, blank keyword).
value: Option<Value>,
/// The text of a commentary card.
text: String,
/// A `HIERARCH` keyword (`key` holds its long name).
hierarch: bool,
}
fn parse_value(field: &str) -> Value {
let t = field.trim_start();
if let Some(rest) = t.strip_prefix('\'') {
let mut s = String::new();
let mut chars = rest.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
s.push('\'');
} else {
break;
}
} else {
s.push(c);
}
}
Value::Str(s.trim_end().to_string())
} else {
Value::Raw(t.split('/').next().unwrap_or("").trim().to_string())
}
}
fn parse_card(raw: &[u8]) -> Card {
// Header bytes are ASCII 32–126 by the standard; anything else is replaced, never trusted.
let s: String = raw.iter().map(|&b| if (32..=126).contains(&b) { b as char } else { '?' }).collect();
let key_field = s.get(..8).unwrap_or(&s).trim_end().to_string();
if key_field == "HIERARCH" {
let rest = s.get(9..).unwrap_or("");
if let Some((k, v)) = rest.split_once('=') {
return Card { key: k.trim().to_string(), value: Some(parse_value(v)), text: String::new(), hierarch: true };
}
}
if s.get(8..10) == Some("= ") {
let v = parse_value(s.get(10..).unwrap_or(""));
return Card { key: key_field, value: Some(v), text: String::new(), hierarch: false };
}
Card { key: key_field, value: None, text: s.get(8..).unwrap_or("").trim_end().to_string(), hierarch: false }
}
struct Header {
cards: Vec<Card>,
/// Offset of the data unit.
data_start: usize,
}
impl Header {
fn get(&self, key: &str) -> Option<&Value> {
self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref())
}
fn int(&self, key: &str) -> Option<i64> {
match self.get(key)? {
Value::Raw(r) => r.parse::<i64>().ok().or_else(|| self.float(key).filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)),
Value::Str(_) => None,
}
}
fn float(&self, key: &str) -> Option<f64> {
match self.get(key)? {
// Fortran-style `D` exponents are allowed in FITS.
Value::Raw(r) => r.replace(['D', 'd'], "E").parse::<f64>().ok().filter(|f| f.is_finite()),
Value::Str(_) => None,
}
}
fn logical(&self, key: &str) -> bool {
matches!(self.get(key), Some(Value::Raw(r)) if r == "T")
}
fn string(&self, key: &str) -> Option<&str> {
match self.get(key)? {
Value::Str(s) => Some(s.trim()),
Value::Raw(_) => None,
}
}
}
fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
let mut cards = Vec::new();
let mut pos = start;
loop {
let raw = b.get(pos..pos.checked_add(CARD).ok_or_else(|| err("header too long"))?).ok_or_else(|| err("header ends before END"))?;
pos += CARD;
if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) {
break;
}
cards.push(parse_card(raw));
}
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
Ok(Header { cards, data_start })
}
/// The axis lengths (`NAXIS1`, `NAXIS2`, …).
fn axes(h: &Header) -> Result<Vec<u64>, CodecError> {
let n = h.int("NAXIS").ok_or_else(|| err("missing NAXIS"))?;
if !(0..=999).contains(&n) {
return Err(err(format!("invalid NAXIS {n}")));
}
(1..=n).map(|i| h.int(&format!("NAXIS{i}")).and_then(|v| u64::try_from(v).ok()).ok_or_else(|| err(format!("missing or negative NAXIS{i}")))).collect()
}
/// Bytes in the data unit (before padding to a block).
fn data_len(h: &Header, bitpix: i64) -> Result<u64, CodecError> {
let ax = axes(h)?;
if ax.is_empty() {
return Ok(0);
}
let pcount = u64::try_from(h.int("PCOUNT").unwrap_or(0)).map_err(|_| err("negative PCOUNT"))?;
let gcount = u64::try_from(h.int("GCOUNT").unwrap_or(1)).map_err(|_| err("negative GCOUNT"))?;
let elems = ax.iter().try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("data size overflows"))?;
elems
.checked_add(pcount)
.and_then(|v| v.checked_mul(gcount))
.and_then(|v| v.checked_mul(bitpix.unsigned_abs() / 8))
.ok_or_else(|| err("data size overflows"))
}
/// Finds the image HDU: the primary one when it has at least two axes, else the first `IMAGE`
/// extension. Returns its header and the primary header (for its descriptive cards).
fn find_image(b: &[u8]) -> Result<(Header, Option<Header>), CodecError> {
let primary = parse_header(b, 0)?;
if primary.get("SIMPLE").is_none() {
return Err(err("missing SIMPLE"));
}
if axes(&primary)?.len() >= 2 {
return Ok((primary, None));
}
let mut compressed = false;
let mut cur_start = primary.data_start;
let mut cur_len = data_len(&primary, primary.int("BITPIX").unwrap_or(8))?;
for _ in 0..MAX_HDUS {
let next = usize::try_from(cur_len.div_ceil(BLOCK as u64).saturating_mul(BLOCK as u64))
.ok()
.and_then(|l| cur_start.checked_add(l))
.ok_or_else(|| err("data size overflows"))?;
if next >= b.len() {
break;
}
let h = parse_header(b, next)?;
let kind = h.string("XTENSION").unwrap_or("").to_string();
if kind == "IMAGE" && axes(&h)?.len() >= 2 {
return Ok((h, Some(primary)));
}
if kind == "BINTABLE" && h.logical("ZIMAGE") {
compressed = true;
}
cur_len = data_len(&h, h.int("BITPIX").unwrap_or(8))?;
cur_start = h.data_start;
}
if compressed {
return Err(CodecError::unsupported(F, "tile-compressed FITS (fpack) is not supported; decompress it with funpack first"));
}
Err(err("the file holds no image"))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (h, primary) = find_image(bytes)?;
let bitpix = h.int("BITPIX").ok_or_else(|| err("missing BITPIX"))?;
if !matches!(bitpix, 8 | 16 | 32 | 64 | -32 | -64) {
return Err(err(format!("invalid BITPIX {bitpix}")));
}
let ax = axes(&h)?;
let (Some(&w), Some(&hgt)) = (ax.first(), ax.get(1)) else { return Err(err("the image has fewer than two axes")) };
let w = u32::try_from(w).map_err(|_| CodecError::LimitExceeded(format!("width {w}")))?;
let hgt = u32::try_from(hgt).map_err(|_| CodecError::LimitExceeded(format!("height {hgt}")))?;
let planes = ax.iter().skip(2).try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("too many planes"))?;
let (layout, nplanes) = if ax.get(2) == Some(&3) && planes == 3 { (ChannelLayout::Rgb, 3usize) } else { (ChannelLayout::Gray, 1) };
let bscale = h.float("BSCALE").unwrap_or(1.0);
let bzero = h.float("BZERO").unwrap_or(0.0);
let blank = if bitpix > 0 { h.int("BLANK") } else { None };
let integral = bscale == 1.0 && bzero.fract() == 0.0;
let candidate = match bitpix {
8 if integral && bzero == 0.0 => SampleType::U8,
8 | 16 if integral => SampleType::U16,
_ => SampleType::F32,
};
limits.check(w, hgt, layout, candidate)?;
let bps = (bitpix.unsigned_abs() / 8) as usize;
let plane_len = (w as usize).checked_mul(hgt as usize).ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{hgt} does not fit in memory")))?;
let n = plane_len.checked_mul(nplanes).ok_or_else(|| CodecError::LimitExceeded("image does not fit in memory".into()))?;
let need = n.checked_mul(bps).and_then(|l| l.checked_add(h.data_start)).ok_or_else(|| err("data size overflows"))?;
let data = bytes.get(h.data_start..need).ok_or_else(|| err("truncated data"))?;
// Physical value of sample `i` (planar order); `None` for a blank or NaN pixel.
let physical = |i: usize| -> Option<f64> {
let s = data.get(i * bps..(i + 1) * bps)?;
let raw = match bitpix {
8 => s.first().map(|&v| v as i64)?,
16 => i16::from_be_bytes([s[0], s[1]]) as i64,
32 => i32::from_be_bytes([s[0], s[1], s[2], s[3]]) as i64,
64 => i64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]),
-32 => return Some(f32::from_be_bytes([s[0], s[1], s[2], s[3]]) as f64 * bscale + bzero).filter(|v| v.is_finite()),
_ => return Some(f64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]) * bscale + bzero).filter(|v| v.is_finite()),
};
if blank == Some(raw) {
return None;
}
Some(raw as f64 * bscale + bzero)
};
let top_down = h.string("ROWORDER").is_some_and(|r| r.eq_ignore_ascii_case("TOP-DOWN"));
let (wu, hu) = (w as usize, hgt as usize);
// Planar, bottom-up (by default) → interleaved, top-down.
let dest = |i: usize| -> usize {
let (p, rem) = (i / plane_len, i % plane_len);
let (y, x) = (rem / wu, rem % wu);
let y = if top_down { y } else { hu - 1 - y };
(y * wu + x) * nplanes + p
};
let mut img = match candidate {
SampleType::U8 => {
let mut out = vec![0u8; n];
for i in 0..n {
out[dest(i)] = physical(i).unwrap_or(0.0) as u8;
}
Image::from_u8(w, hgt, layout, out)?
}
_ => {
let mut out16 = if candidate == SampleType::U16 { Some(vec![0u16; n]) } else { None };
if let Some(out) = out16.as_mut() {
for i in 0..n {
let v = physical(i).unwrap_or(0.0);
if !(0.0..=65535.0).contains(&v) {
// Signed data with negative values: fall back to float.
out16 = None;
break;
}
out[dest(i)] = v as u16;
}
}
match out16 {
Some(out) => Image::from_u16(w, hgt, layout, &out)?,
None => {
let max = (0..n).filter_map(physical).fold(0.0f64, f64::max);
let white = if max <= 2.0 {
1.0
} else if max <= 65535.0 {
65535.0
} else {
max
};
let mut out = vec![0f32; n];
for i in 0..n {
out[dest(i)] = (physical(i).unwrap_or(0.0) / white) as f32;
}
Image::from_f32(w, hgt, layout, &out)?
}
}
}
};
if planes > nplanes as u64 {
img.warnings.push(DecodeWarning::MorePages { total: u32::try_from(planes).ok() });
}
let mut text = Vec::new();
for c in primary.iter().flat_map(|p| p.cards.iter()).chain(h.cards.iter()) {
if !c.hierarch && (STRUCTURAL.contains(&c.key.as_str()) || is_naxis(&c.key)) {
continue;
}
let entry = match &c.value {
Some(Value::Str(s) | Value::Raw(s)) => (c.key.clone(), s.clone()),
None if c.key.is_empty() && c.text.trim().is_empty() => continue,
None => (if c.key.is_empty() { "COMMENT".to_string() } else { c.key.clone() }, c.text.trim().to_string()),
};
text.push(entry);
}
img.meta.text = text;
Ok(img)
}
fn is_naxis(key: &str) -> bool {
key.strip_prefix("NAXIS").is_some_and(|d| d.chars().all(|c| c.is_ascii_digit()))
}
/// A standard keyword: 1–8 characters from `A-Z 0-9 _ -`.
fn standard_key(key: &str) -> bool {
(1..=8).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
fn ascii(s: &str) -> String {
s.chars().map(|c| if (' '..='~').contains(&c) { c } else { '?' }).collect()
}
/// A FITS literal (number or logical) that can be written unquoted.
fn literal(v: &str) -> bool {
v == "T" || v == "F" || (v.replace(['D', 'd'], "E").parse::<f64>().is_ok_and(|f| f.is_finite()) && v.bytes().all(|b| b"0123456789+-.EeDd".contains(&b)))
}
fn quoted(v: &str, room: usize) -> String {
let mut s = String::from("'");
for c in ascii(v).chars() {
let add = if c == '\'' { 2 } else { 1 };
// Keep room for the closing quote.
if s.len() + add + 1 > room {
break;
}
s.push(c);
if c == '\'' {
s.push('\'');
}
}
// Strings are at least eight characters inside the quotes.
while s.len() < 9 {
s.push(' ');
}
s.push('\'');
s
}
struct Writer {
out: Vec<u8>,
}
impl Writer {
fn card(&mut self, text: &str) {
let mut c: Vec<u8> = ascii(text).into_bytes();
c.truncate(CARD);
c.resize(CARD, b' ');
self.out.extend_from_slice(&c);
}
fn value(&mut self, key: &str, v: &str) {
if literal(v) {
self.card(&format!("{key:<8}= {v:>20}"));
} else {
self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10)));
}
}
fn commentary(&mut self, key: &str, text: &str) {
let chars: Vec<char> = ascii(text).chars().collect();
if chars.is_empty() {
self.card(key);
}
for chunk in chars.chunks(CARD - 8) {
self.card(&format!("{key:<8}{}", chunk.iter().collect::<String>()));
}
}
fn text(&mut self, key: &str, v: &str) {
let upper = key.to_ascii_uppercase();
if matches!(upper.as_str(), "COMMENT" | "HISTORY") {
self.commentary(&upper, v);
} else if standard_key(key) {
if !STRUCTURAL.contains(&key) && !is_naxis(key) {
self.value(key, v);
}
} else {
// Long or mixed-case names (`Description`, `Software`): the HIERARCH convention,
// when the card fits, else a comment.
let name = ascii(key);
let head = format!("HIERARCH {name} = ");
let fits_key = !name.contains('=') && !name.trim().is_empty();
let body = if literal(v) { v.to_string() } else { quoted(v, CARD.saturating_sub(head.len())) };
if fits_key && head.len() + body.len() <= CARD && (literal(v) || body.ends_with('\'')) && quoted_whole(v, &body) {
self.card(&format!("{head}{body}"));
} else {
self.commentary("COMMENT", &format!("{key} = {v}"));
}
}
}
}
/// `body` holds all of `v` (it was not cut short to fit the card).
fn quoted_whole(v: &str, body: &str) -> bool {
literal(v) || body.trim_matches('\'').trim_end().replace("''", "'") == ascii(v).trim_end()
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let layout = img.layout();
let nc = match layout {
ChannelLayout::Gray => 1,
ChannelLayout::Rgb => 3,
l => return Err(CodecError::encode(F, format!("FITS cannot store {l:?}"))),
};
let bitpix = match img.sample_type() {
SampleType::U8 => 8,
SampleType::U16 => 16,
SampleType::F32 => -32,
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
};
let mut wr = Writer { out: Vec::new() };
wr.value("SIMPLE", "T");
wr.value("BITPIX", &bitpix.to_string());
wr.value("NAXIS", if nc == 3 { "3" } else { "2" });
wr.value("NAXIS1", &w.to_string());
wr.value("NAXIS2", &h.to_string());
if nc == 3 {
wr.value("NAXIS3", "3");
}
if bitpix == 16 {
wr.value("BZERO", "32768");
wr.value("BSCALE", "1");
}
wr.value("ROWORDER", "BOTTOM-UP");
if opts.embed_metadata {
for (k, v) in &img.meta.text {
wr.text(k, v);
}
}
wr.card("END");
let mut out = wr.out;
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
// Planar, bottom row first.
let (wu, hu) = (w as usize, h as usize);
let idx = |p: usize, y: usize, x: usize| ((hu - 1 - y) * wu + x) * nc + p;
match bitpix {
8 => {
let d = img.data();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.push(d[idx(p, y, x)]);
}
}
}
}
16 => {
let d = img.to_u16_samples().unwrap_or_default();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&((d[idx(p, y, x)] as i32 - 32768) as i16).to_be_bytes());
}
}
}
}
_ => {
let d = img.to_f32_samples().unwrap_or_default();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
}
}
}
}
}
out.resize(out.len().div_ceil(BLOCK) * BLOCK, 0);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn header(cards: &[&str]) -> Vec<u8> {
let mut w = Writer { out: Vec::new() };
for c in cards {
w.card(c);
}
w.card("END");
let mut out = w.out;
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
out
}
#[test]
fn camera_u16_with_bzero_reads_exactly_and_bottom_up() {
let mut b = header(&[
"SIMPLE = T",
"BITPIX = 16",
"NAXIS = 2",
"NAXIS1 = 2",
"NAXIS2 = 2",
"BZERO = 32768",
"OBJECT = 'M 31 '",
"EXPTIME = 300.0 / seconds",
]);
for v in [0u16, 1000, 40000, 65535] {
b.extend_from_slice(&((v as i32 - 32768) as i16).to_be_bytes());
}
b.resize(b.len().div_ceil(BLOCK) * BLOCK, 0);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.sample_type(), SampleType::U16);
// The first stored row is the bottom one.
assert_eq!(img.to_u16_samples().unwrap(), vec![40000, 65535, 0, 1000]);
assert!(img.meta.text.contains(&("OBJECT".into(), "M 31".into())));
assert!(img.meta.text.contains(&("EXPTIME".into(), "300.0".into())));
assert!(!img.meta.text.iter().any(|(k, _)| k == "BZERO" || k == "NAXIS1"));
}
#[test]
fn roworder_top_down_is_honoured() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 2", "ROWORDER= 'TOP-DOWN'"]);
b.extend_from_slice(&[10, 20]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[10, 20]);
}
#[test]
fn stacked_float_in_adu_is_scaled_by_65535() {
let mut b = header(&["SIMPLE = T", "BITPIX = -32", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
for v in [65535.0f32, f32::NAN] {
b.extend_from_slice(&v.to_be_bytes());
}
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
}
#[test]
fn signed_negative_data_falls_back_to_float() {
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
for v in [-100i16, 1000] {
b.extend_from_slice(&v.to_be_bytes());
}
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.sample_type(), SampleType::F32);
}
#[test]
fn image_extension_after_empty_primary() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0", "EXTEND = T", "TELESCOP= 'Seestar S50'"]);
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1", "PCOUNT = 0", "GCOUNT = 1"]));
b.push(42);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[42]);
assert!(img.meta.text.contains(&("TELESCOP".into(), "Seestar S50".into())));
}
#[test]
fn fpack_is_refused_as_unsupported() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0", "ZIMAGE = T"]));
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 1", "NAXIS1 = 0"]));
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
}
/// Hostile headers: absurd or negative axes, bad BITPIX, overflowing sizes, missing END,
/// a long chain of empty HDUs and declared data past the end all error, never panic or
/// allocate the declared size.
#[test]
fn hostile_headers_error_without_panicking() {
let base = ["SIMPLE = T", "BITPIX = 16"];
let cases: &[&[&str]] = &[
&["NAXIS = 2", "NAXIS1 = 4294967296", "NAXIS2 = 1"],
&["NAXIS = 2", "NAXIS1 = -5", "NAXIS2 = 1"],
&["NAXIS = 2", "NAXIS1 = 2000000", "NAXIS2 = 2000000"],
&["NAXIS = 3", "NAXIS1 = 65536", "NAXIS2 = 65536", "NAXIS3 = 9223372036854775807"],
&["NAXIS = 1000"],
&["NAXIS = -1"],
&["NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0"],
&["NAXIS = 2", "NAXIS1 = 1.5D300", "NAXIS2 = 1"],
&["NAXIS = 2"],
&["NAXIS = 2", "NAXIS1 = 100", "NAXIS2 = 100"],
];
for extra in cases {
let cards: Vec<&str> = base.iter().chain(extra.iter()).copied().collect();
let b = header(&cards);
assert!(decode(&b, &Limits::default()).is_err(), "{extra:?}");
}
for bitpix in ["0", "7", "-16", "128", "'X'"] {
let card = format!("BITPIX = {bitpix}");
let b = header(&["SIMPLE = T", &card, "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1"]);
assert!(decode(&b, &Limits::default()).is_err(), "BITPIX {bitpix}");
}
// No END card: the header runs off the end of the file.
let mut w = Writer { out: Vec::new() };
for c in ["SIMPLE = T", "BITPIX = 8", "NAXIS = 2"] {
w.card(c);
}
assert!(decode(&w.out, &Limits::default()).is_err());
// An empty primary followed by many empty extensions: bounded walk, then "no image".
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
for _ in 0..(MAX_HDUS + 5) {
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 0"]));
}
assert!(decode(&b, &Limits::default()).is_err());
// An extension whose declared data size overflows.
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
b.extend(header(&[
"XTENSION= 'BINTABLE'",
"BITPIX = 64",
"NAXIS = 2",
"NAXIS1 = 9223372036854775807",
"NAXIS2 = 4",
"PCOUNT = 0",
"GCOUNT = 1",
]));
assert!(decode(&b, &Limits::default()).is_err());
}
#[test]
fn extra_planes_warn() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 3", "NAXIS1 = 1", "NAXIS2 = 1", "NAXIS3 = 4"]);
b.extend_from_slice(&[1, 2, 3, 4]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.warnings, vec![DecodeWarning::MorePages { total: Some(4) }]);
}
#[test]
fn strings_with_quotes_and_hierarch_round_trip() {
let mut w = Writer { out: Vec::new() };
w.text("OBSERVER", "O'Brien");
w.text("Software", "photocraft 1.0");
w.text("HISTORY", "stacked");
let cards: Vec<Card> = w.out.chunks(CARD).map(parse_card).collect();
assert_eq!(cards[0].value, Some(Value::Str("O'Brien".into())));
assert!(cards[1].hierarch && cards[1].key == "Software");
assert_eq!(cards[1].value, Some(Value::Str("photocraft 1.0".into())));
assert_eq!((cards[2].key.as_str(), cards[2].text.as_str()), ("HISTORY", "stacked"));
}
}

View File

@ -1,6 +1,7 @@
pub(crate) mod deep_exr;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
pub(crate) mod fits;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod png;

View File

@ -23,6 +23,8 @@ pub enum Format {
Avif,
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
Heif,
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
Fits,
}
/// What a format can hold **and** what this crate reads/writes for it.
@ -76,7 +78,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 14] = [
pub const ALL: [Format; 15] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@ -91,6 +93,7 @@ impl Format {
Format::Hdr,
Format::Avif,
Format::Heif,
Format::Fits,
];
pub fn caps(self) -> FormatCaps {
@ -119,6 +122,7 @@ impl Format {
Format::Hdr => "Radiance HDR",
Format::Avif => "AVIF",
Format::Heif => "HEIF",
Format::Fits => "FITS",
}
}
@ -140,6 +144,7 @@ impl Format {
Format::Hdr => &["hdr"],
Format::Avif => &["avif"],
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
}
}
@ -159,6 +164,7 @@ impl Format {
Format::Hdr => "image/vnd.radiance",
Format::Avif => "image/avif",
Format::Heif => "image/heif",
Format::Fits => "image/fits",
}
}
@ -208,6 +214,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Heif => {
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
}
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
}
}
@ -256,6 +263,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
return Some(Format::Pnm);
}
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
if looks_like_tga(b) {
return Some(Format::Tga);
}

View File

@ -38,7 +38,7 @@ pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use half::f16;
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@ -69,6 +69,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Tiff => tiff::decode(bytes, l),
Format::WebP => webp::decode(bytes, l),
Format::Pnm => pnm::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
@ -203,6 +204,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Tiff => tiff::encode(image, plan, opts),
Format::WebP => webp::encode(image, plan, opts),
Format::Pnm => pnm::encode(image, plan, opts),
Format::Fits => fits::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),

View File

@ -58,6 +58,7 @@ fn default_build_formats_all_symmetric() {
Format::Qoi,
Format::OpenExr,
Format::Hdr,
Format::Fits,
];
for f in expected {
assert!(caps(f).read && caps(f).write, "{f:?}");
@ -245,6 +246,8 @@ fn extension_specific_cases() {
assert_eq!(from_extension("tif"), Some(Format::Tiff));
assert_eq!(from_extension("pfm"), Some(Format::Pnm));
assert_eq!(from_extension("pam"), Some(Format::Pnm));
assert_eq!(from_extension("FIT"), Some(Format::Fits));
assert_eq!(from_extension("m31.fits"), Some(Format::Fits));
assert_eq!(from_extension("exr"), Some(Format::OpenExr));
assert_eq!(from_extension("psd"), None);
assert_eq!(from_extension(""), None);

View File

@ -60,7 +60,7 @@ fn truncated_files_mostly_error() {
// Chopping off the second half must not silently succeed for formats
// with length-checked rasters.
for (f, bytes) in samples() {
if matches!(f, Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
assert!(r.is_err(), "{f:?} decoded half a file");
}

View File

@ -94,6 +94,10 @@ fn ico_metadata() {
check_format(Format::Ico);
}
#[test]
fn fits_metadata() {
check_format(Format::Fits);
}
#[test]
fn pnm_metadata() {
check_format(Format::Pnm);
}

View File

@ -131,6 +131,9 @@ mod ico {
mod pnm {
cases!(Format::Pnm);
}
mod fits {
cases!(Format::Fits);
}
mod qoi {
cases!(Format::Qoi);
}

View File

@ -118,7 +118,7 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
#[cfg(not(target_arch = "wasm32"))]
const OPENABLE: &[&str] = &[
"pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm",
"heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
"fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
];
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone

View File

@ -50,6 +50,23 @@ codec_rt!(tiff_rgb16, "tif", ColorMode::Rgb, SampleType::U16, false, 0.0);
codec_rt!(tiff_cmyk8, "tiff", ColorMode::Cmyk, SampleType::U8, false, 0.0);
codec_rt!(tiff_cmyka16, "tiff", ColorMode::Cmyk, SampleType::U16, true, 0.0);
codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0);
codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0);
codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0);
codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0);
/// FITS header cards travel as the document's text metadata, so an astro frame keeps its
/// OBJECT, EXPTIME and the rest through an edit.
#[test]
fn fits_header_cards_round_trip_as_text() {
let mut d = single(ColorMode::Grayscale, SampleType::U16, false);
d.metadata.text = vec![("OBJECT".into(), "NGC 246".into()), ("EXPTIME".into(), "480.0".into())];
let r = export(&d, "a.fits", &ExportOptions::default()).unwrap();
assert!(r.bytes.starts_with(b"SIMPLE ="));
let back = import("a.fits", &r.bytes).unwrap().document;
for kv in &d.metadata.text {
assert!(back.metadata.text.contains(kv), "{kv:?}: {:?}", back.metadata.text);
}
}
/// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is
/// linearised on export and comes back tagged linear sRGB with the same colours.

View File

@ -36,6 +36,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| OpenEXR | R/W | yes | yes | f16/f32; deep EXR and Cryptomatte readers (`corpus/exr`) |
| Radiance HDR | R/W | yes | yes | f32 |
| Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f |
| FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer |
| Targa | R/W | yes | yes | 8-bit |
| Photoshop PDF | R/W | no (#2541) | raster only (print, artboards to PDF) | No vector/text PDF, no multi-page save, no PDF presentation (FILE-215-10) |
| Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | |