| 1 | //! Format sniffing and dispatch to the decoder backends. |
| 2 | |
| 3 | pub mod color; |
| 4 | |
| 5 | use std::fs::File; |
| 6 | use std::io::Read; |
| 7 | use std::path::Path; |
| 8 | use std::sync::LazyLock; |
| 9 | |
| 10 | use anyhow::{Context, anyhow}; |
| 11 | use fast_image_resize as fir; |
| 12 | use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, metadata::Orientation}; |
| 13 | |
| 14 | /// Refuse single images that would need more than this to decode. |
| 15 | const MAX_ALLOC: u64 = 4 << 30; |
| 16 | |
| 17 | /// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload. |
| 18 | pub struct DecodedImage { |
| 19 | pub size: [u32; 2], |
| 20 | pub pixels: Vec<u8>, |
| 21 | pub format: &'static str, |
| 22 | /// Size before any downscale to fit the GPU's texture limit. |
| 23 | pub source_size: [u32; 2], |
| 24 | } |
| 25 | |
| 26 | impl DecodedImage { |
| 27 | pub fn bytes(&self) -> usize { |
| 28 | self.pixels.len() |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | impl std::fmt::Debug for DecodedImage { |
| 33 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 34 | f.debug_struct("DecodedImage") |
| 35 | .field("size", &self.size) |
| 36 | .field("format", &self.format) |
| 37 | .finish_non_exhaustive() |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| { |
| 42 | ImageFormat::all() |
| 43 | .filter(ImageFormat::reading_enabled) |
| 44 | .flat_map(|f| f.extensions_str().iter().copied()) |
| 45 | .chain(["apng", "jfif"]) |
| 46 | .collect() |
| 47 | }); |
| 48 | |
| 49 | /// Whether the extension alone says we can decode this file. |
| 50 | pub fn is_known_extension(path: &Path) -> bool { |
| 51 | path.extension() |
| 52 | .and_then(|e| e.to_str()) |
| 53 | .is_some_and(|e| EXTENSIONS.iter().any(|k| k.eq_ignore_ascii_case(e))) |
| 54 | } |
| 55 | |
| 56 | /// Check magic bytes for files whose extension is unknown. |
| 57 | pub fn sniff(path: &Path) -> bool { |
| 58 | let mut buf = [0u8; 64]; |
| 59 | let Ok(mut f) = File::open(path) else { |
| 60 | return false; |
| 61 | }; |
| 62 | let Ok(n) = f.read(&mut buf) else { |
| 63 | return false; |
| 64 | }; |
| 65 | image::guess_format(&buf[..n]).is_ok_and(|f| f.reading_enabled()) |
| 66 | } |
| 67 | |
| 68 | fn label(format: ImageFormat) -> &'static str { |
| 69 | match format { |
| 70 | ImageFormat::Png => "PNG", |
| 71 | ImageFormat::Jpeg => "JPEG", |
| 72 | ImageFormat::Gif => "GIF", |
| 73 | ImageFormat::WebP => "WEBP", |
| 74 | ImageFormat::Pnm => "PNM", |
| 75 | ImageFormat::Tiff => "TIFF", |
| 76 | ImageFormat::Tga => "TGA", |
| 77 | ImageFormat::Dds => "DDS", |
| 78 | ImageFormat::Bmp => "BMP", |
| 79 | ImageFormat::Ico => "ICO", |
| 80 | ImageFormat::Hdr => "HDR", |
| 81 | ImageFormat::OpenExr => "EXR", |
| 82 | ImageFormat::Farbfeld => "FARBFELD", |
| 83 | ImageFormat::Avif => "AVIF", |
| 84 | ImageFormat::Qoi => "QOI", |
| 85 | _ => "IMAGE", |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Decode `path`, downscaling if either side exceeds `max_dim` (the GPU texture limit). |
| 90 | pub fn decode(path: &Path, max_dim: u32) -> anyhow::Result<DecodedImage> { |
| 91 | // `open` guesses from the extension; magic bytes take precedence when recognised. |
| 92 | let mut reader = ImageReader::open(path)?.with_guessed_format()?; |
| 93 | let mut limits = image::Limits::default(); |
| 94 | limits.max_alloc = Some(MAX_ALLOC); |
| 95 | reader.limits(limits); |
| 96 | let format = reader.format().ok_or_else(|| anyhow!("unknown format"))?; |
| 97 | let mut decoder = reader.into_decoder()?; |
| 98 | let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); |
| 99 | let mut img = DynamicImage::from_decoder(decoder)?; |
| 100 | img.apply_orientation(orientation); |
| 101 | let rgba = img.into_rgba8(); |
| 102 | let source_size = [rgba.width(), rgba.height()]; |
| 103 | let mut out = DecodedImage { |
| 104 | size: source_size, |
| 105 | pixels: rgba.into_raw(), |
| 106 | format: label(format), |
| 107 | source_size, |
| 108 | }; |
| 109 | if source_size[0] > max_dim || source_size[1] > max_dim { |
| 110 | out = downscale(out, max_dim)?; |
| 111 | } |
| 112 | Ok(out) |
| 113 | } |
| 114 | |
| 115 | /// Shrink so neither side exceeds `max_dim`. A stopgap until M5 tiles huge images. |
| 116 | pub fn downscale(img: DecodedImage, max_dim: u32) -> anyhow::Result<DecodedImage> { |
| 117 | let [w, h] = img.size; |
| 118 | let k = max_dim as f64 / w.max(h) as f64; |
| 119 | let (nw, nh) = ( |
| 120 | ((w as f64 * k) as u32).max(1), |
| 121 | ((h as f64 * k) as u32).max(1), |
| 122 | ); |
| 123 | let src = fir::images::Image::from_vec_u8(w, h, img.pixels, fir::PixelType::U8x4) |
| 124 | .context("resize source")?; |
| 125 | let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4); |
| 126 | fir::Resizer::new() |
| 127 | .resize(&src, &mut dst, None) |
| 128 | .context("resize")?; |
| 129 | log::debug!("downscaled {w}×{h} to {nw}×{nh} for the GPU"); |
| 130 | Ok(DecodedImage { |
| 131 | size: [nw, nh], |
| 132 | pixels: dst.into_vec(), |
| 133 | ..img |
| 134 | }) |
| 135 | } |
| 136 | |
| 137 | #[cfg(test)] |
| 138 | mod tests { |
| 139 | use super::*; |
| 140 | |
| 141 | #[test] |
| 142 | fn known_extensions() { |
| 143 | assert!(is_known_extension(Path::new("a/b.JPG"))); |
| 144 | assert!(is_known_extension(Path::new("b.png"))); |
| 145 | assert!(!is_known_extension(Path::new("b.txt"))); |
| 146 | assert!(!is_known_extension(Path::new("png"))); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn decodes_and_downscales_png() { |
| 151 | let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id())); |
| 152 | std::fs::create_dir_all(&dir).unwrap(); |
| 153 | let path = dir.join("noext"); |
| 154 | image::RgbaImage::from_pixel(64, 32, image::Rgba([255, 0, 0, 128])) |
| 155 | .save_with_format(&path, ImageFormat::Png) |
| 156 | .unwrap(); |
| 157 | assert!(sniff(&path)); |
| 158 | let img = decode(&path, 16).unwrap(); |
| 159 | assert_eq!(img.format, "PNG"); |
| 160 | assert_eq!(img.source_size, [64, 32]); |
| 161 | assert_eq!(img.size, [16, 8]); |
| 162 | assert_eq!(img.pixels.len(), 16 * 8 * 4); |
| 163 | std::fs::remove_dir_all(&dir).unwrap(); |
| 164 | } |
| 165 | } |