| 1 | //! The `image` crate: stills (with ICC and EXIF orientation), GIF/APNG/WebP animation, and |
| 2 | //! multi-page TIFF. |
| 3 | |
| 4 | use std::fs::File; |
| 5 | use std::io::{BufRead, BufReader, Cursor, Seek}; |
| 6 | use std::path::Path; |
| 7 | use std::time::Duration; |
| 8 | |
| 9 | use anyhow::{Context, anyhow}; |
| 10 | use image::codecs::gif::GifDecoder; |
| 11 | use image::codecs::png::PngDecoder; |
| 12 | use image::codecs::webp::WebPDecoder; |
| 13 | use image::metadata::Orientation; |
| 14 | use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader}; |
| 15 | |
| 16 | use super::color::{self, ToSrgb}; |
| 17 | use super::{DecodedImage, Frame, MAX_ALLOC, MAX_FRAMES_BYTES, Options, label}; |
| 18 | |
| 19 | /// Browsers show GIF frames with tiny delays at this pace, and so do we. |
| 20 | const MIN_DELAY: Duration = Duration::from_millis(20); |
| 21 | const DEFAULT_DELAY: Duration = Duration::from_millis(100); |
| 22 | |
| 23 | fn limits() -> image::Limits { |
| 24 | let mut l = image::Limits::default(); |
| 25 | l.max_alloc = Some(MAX_ALLOC); |
| 26 | l |
| 27 | } |
| 28 | |
| 29 | pub fn decode_file( |
| 30 | path: &Path, |
| 31 | format: ImageFormat, |
| 32 | opts: &Options, |
| 33 | ) -> anyhow::Result<DecodedImage> { |
| 34 | let open = || -> anyhow::Result<BufReader<File>> { Ok(BufReader::new(File::open(path)?)) }; |
| 35 | let icc = |d: &mut dyn ImageDecoder| { |
| 36 | if opts.color { |
| 37 | d.icc_profile().ok().flatten() |
| 38 | } else { |
| 39 | None |
| 40 | } |
| 41 | }; |
| 42 | let animated = match format { |
| 43 | ImageFormat::Gif => { |
| 44 | let mut d = GifDecoder::new(open()?)?; |
| 45 | let icc = icc(&mut d); |
| 46 | animation(d, icc, format)? |
| 47 | } |
| 48 | ImageFormat::Png => { |
| 49 | let mut d = PngDecoder::new(open()?)?; |
| 50 | if d.is_apng()? { |
| 51 | let icc = icc(&mut d); |
| 52 | animation(d.apng()?, icc, format)? |
| 53 | } else { |
| 54 | None |
| 55 | } |
| 56 | } |
| 57 | ImageFormat::WebP => { |
| 58 | let mut d = WebPDecoder::new(open()?)?; |
| 59 | if d.has_animation() { |
| 60 | let icc = icc(&mut d); |
| 61 | animation(d, icc, format)? |
| 62 | } else { |
| 63 | None |
| 64 | } |
| 65 | } |
| 66 | _ => None, |
| 67 | }; |
| 68 | if let Some(img) = animated { |
| 69 | return Ok(img); |
| 70 | } |
| 71 | if format == ImageFormat::Jpeg { |
| 72 | match decode_jpeg(path, opts) { |
| 73 | Ok(Some(img)) => return Ok(img), |
| 74 | Ok(None) => {} |
| 75 | Err(e) => log::debug!("fast JPEG path failed, retrying: {e:#}"), |
| 76 | } |
| 77 | } |
| 78 | let mut img = decode_reader(open()?, format, opts)?; |
| 79 | if format == ImageFormat::Tiff { |
| 80 | add_tiff_pages(path, &mut img, opts); |
| 81 | } |
| 82 | Ok(img) |
| 83 | } |
| 84 | |
| 85 | /// JPEG straight to RGBA through zune-jpeg. `image` would hand over RGB, and widening 24 MP |
| 86 | /// costs a quarter of the decode again. `None` for the colour spaces (CMYK, YCCK, plain RGB) |
| 87 | /// that zune-jpeg can't write as RGBA; those go the long way. |
| 88 | fn decode_jpeg(path: &Path, opts: &Options) -> anyhow::Result<Option<DecodedImage>> { |
| 89 | use zune_core::colorspace::ColorSpace; |
| 90 | let data = std::fs::read(path)?; |
| 91 | let options = zune_core::options::DecoderOptions::default() |
| 92 | .set_strict_mode(false) |
| 93 | .set_max_width(usize::MAX) |
| 94 | .set_max_height(usize::MAX) |
| 95 | .jpeg_set_out_colorspace(ColorSpace::RGBA); |
| 96 | let mut d = zune_jpeg::JpegDecoder::new_with_options( |
| 97 | zune_core::bytestream::ZCursor::new(data.as_slice()), |
| 98 | options, |
| 99 | ); |
| 100 | d.decode_headers()?; |
| 101 | if !matches!( |
| 102 | d.input_colorspace(), |
| 103 | Some(ColorSpace::YCbCr | ColorSpace::Luma) |
| 104 | ) { |
| 105 | return Ok(None); |
| 106 | } |
| 107 | let (w, h) = d.dimensions().context("no JPEG header")?; |
| 108 | if (w as u64) * (h as u64) * 4 > MAX_ALLOC { |
| 109 | return Err(anyhow!("image too large")); |
| 110 | } |
| 111 | let orientation = d |
| 112 | .exif() |
| 113 | .filter(|_| opts.auto_orient) |
| 114 | .and_then(|e| Orientation::from_exif_chunk(e)) |
| 115 | .unwrap_or(Orientation::NoTransforms); |
| 116 | let icc = if opts.color { d.icc_profile() } else { None }; |
| 117 | let mut pixels = d.decode()?; |
| 118 | if pixels.len() != w * h * 4 { |
| 119 | return Err(anyhow!("JPEG decoded to an unexpected size")); |
| 120 | } |
| 121 | if let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) { |
| 122 | t.apply(&mut pixels); |
| 123 | } |
| 124 | let size = [w as u32, h as u32]; |
| 125 | Ok(Some( |
| 126 | DecodedImage::still(size, pixels, label(ImageFormat::Jpeg)).turned(orientation), |
| 127 | )) |
| 128 | } |
| 129 | |
| 130 | /// A still image from any reader: ICC to sRGB, float tone-mapped. Orientation is left to the |
| 131 | /// renderer. |
| 132 | pub fn decode_reader<R: BufRead + Seek>( |
| 133 | reader: R, |
| 134 | format: ImageFormat, |
| 135 | opts: &Options, |
| 136 | ) -> anyhow::Result<DecodedImage> { |
| 137 | let mut reader = ImageReader::with_format(reader, format); |
| 138 | reader.limits(limits()); |
| 139 | let mut decoder = reader.into_decoder()?; |
| 140 | let orientation = if opts.auto_orient { |
| 141 | decoder.orientation().unwrap_or(Orientation::NoTransforms) |
| 142 | } else { |
| 143 | Orientation::NoTransforms |
| 144 | }; |
| 145 | let icc = if opts.color { |
| 146 | decoder.icc_profile().ok().flatten() |
| 147 | } else { |
| 148 | None |
| 149 | }; |
| 150 | let img = DynamicImage::from_decoder(decoder)?; |
| 151 | let float = color::is_float(&img); |
| 152 | let (size, mut pixels) = color::to_rgba8(img); |
| 153 | // Float data is linear scene light; an embedded profile doesn't describe the 8-bit result. |
| 154 | if !float && let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) { |
| 155 | t.apply(&mut pixels); |
| 156 | } |
| 157 | Ok(DecodedImage::still(size, pixels, label(format)).turned(orientation)) |
| 158 | } |
| 159 | |
| 160 | /// Decode from memory, guessing the format from the bytes (external decoders' output). |
| 161 | pub fn decode_bytes(bytes: &[u8], opts: &Options) -> anyhow::Result<DecodedImage> { |
| 162 | let format = image::guess_format(bytes).context("unrecognised output")?; |
| 163 | decode_reader(Cursor::new(bytes), format, opts) |
| 164 | } |
| 165 | |
| 166 | /// All frames of an animation, or `None` when it has only one (decode it as a still, which |
| 167 | /// knows its orientation). |
| 168 | fn animation<'a>( |
| 169 | decoder: impl AnimationDecoder<'a>, |
| 170 | icc: Option<Vec<u8>>, |
| 171 | format: ImageFormat, |
| 172 | ) -> anyhow::Result<Option<DecodedImage>> { |
| 173 | let transform = icc.as_deref().and_then(ToSrgb::from_icc); |
| 174 | let mut frames = Vec::new(); |
| 175 | let mut size = [0, 0]; |
| 176 | let mut bytes = 0; |
| 177 | for frame in decoder.into_frames() { |
| 178 | let frame = frame?; |
| 179 | let (num, den) = frame.delay().numer_denom_ms(); |
| 180 | let ms = num.checked_div(den).unwrap_or(0); |
| 181 | let delay = match Duration::from_millis(ms as u64) { |
| 182 | d if d < MIN_DELAY => DEFAULT_DELAY, |
| 183 | d => d, |
| 184 | }; |
| 185 | let buf = frame.into_buffer(); |
| 186 | size = [buf.width(), buf.height()]; |
| 187 | let mut pixels = buf.into_raw(); |
| 188 | if let Some(t) = &transform { |
| 189 | t.apply(&mut pixels); |
| 190 | } |
| 191 | bytes += pixels.len(); |
| 192 | frames.push(Frame { pixels, delay }); |
| 193 | if bytes > MAX_FRAMES_BYTES { |
| 194 | log::warn!("animation truncated at {} frames", frames.len()); |
| 195 | break; |
| 196 | } |
| 197 | } |
| 198 | match frames.len() { |
| 199 | 0 => Err(anyhow!("no frames")), |
| 200 | 1 => Ok(None), |
| 201 | n => { |
| 202 | log::debug!("{n} frames"); |
| 203 | Ok(Some(DecodedImage { |
| 204 | size, |
| 205 | frames, |
| 206 | animated: true, |
| 207 | format: label(format), |
| 208 | source_size: size, |
| 209 | refine: None, |
| 210 | orientation: Orientation::NoTransforms, |
| 211 | })) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /// Pages after the first, as extra (untimed) frames. Pages of another size or orientation |
| 217 | /// are skipped. |
| 218 | fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) { |
| 219 | let Ok(file) = File::open(path) else { return }; |
| 220 | let Ok(mut d) = tiff::decoder::Decoder::new(BufReader::new(file)) else { |
| 221 | return; |
| 222 | }; |
| 223 | let mut bytes = img.bytes(); |
| 224 | while d.more_images() { |
| 225 | if d.next_image().is_err() { |
| 226 | break; |
| 227 | } |
| 228 | match tiff_page(&mut d) { |
| 229 | Ok((size, pixels)) => { |
| 230 | // Every frame shares the first page's texture and placement. |
| 231 | if size != img.size || orientation_of(&mut d, opts) != img.orientation { |
| 232 | log::debug!("TIFF page of another size skipped"); |
| 233 | continue; |
| 234 | } |
| 235 | bytes += pixels.len(); |
| 236 | img.frames.push(Frame { |
| 237 | pixels, |
| 238 | delay: Duration::ZERO, |
| 239 | }); |
| 240 | } |
| 241 | Err(e) => log::debug!("TIFF page skipped: {e:#}"), |
| 242 | } |
| 243 | if bytes > MAX_FRAMES_BYTES { |
| 244 | log::warn!("multi-page TIFF truncated at {} pages", img.frames.len()); |
| 245 | break; |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | fn orientation_of<R: std::io::Read + Seek>( |
| 251 | d: &mut tiff::decoder::Decoder<R>, |
| 252 | opts: &Options, |
| 253 | ) -> Orientation { |
| 254 | if !opts.auto_orient { |
| 255 | return Orientation::NoTransforms; |
| 256 | } |
| 257 | d.find_tag_unsigned::<u8>(tiff::tags::Tag::Orientation) |
| 258 | .ok() |
| 259 | .flatten() |
| 260 | .and_then(Orientation::from_exif) |
| 261 | .unwrap_or(Orientation::NoTransforms) |
| 262 | } |
| 263 | |
| 264 | fn tiff_page<R: std::io::Read + Seek>( |
| 265 | d: &mut tiff::decoder::Decoder<R>, |
| 266 | ) -> anyhow::Result<([u32; 2], Vec<u8>)> { |
| 267 | use tiff::ColorType as C; |
| 268 | use tiff::decoder::DecodingResult as D; |
| 269 | let (w, h) = d.dimensions()?; |
| 270 | let channels = match d.colortype()? { |
| 271 | C::Gray(8 | 16) => 1, |
| 272 | C::GrayA(8 | 16) => 2, |
| 273 | C::RGB(8 | 16) => 3, |
| 274 | C::RGBA(8 | 16) => 4, |
| 275 | other => return Err(anyhow!("unsupported colour type {other:?}")), |
| 276 | }; |
| 277 | let samples: Vec<u8> = match d.read_image()? { |
| 278 | D::U8(v) => v, |
| 279 | D::U16(v) => v.into_iter().map(|s| (s >> 8) as u8).collect(), |
| 280 | _ => return Err(anyhow!("unsupported sample format")), |
| 281 | }; |
| 282 | if samples.len() < (w * h) as usize * channels { |
| 283 | return Err(anyhow!("short page")); |
| 284 | } |
| 285 | Ok(([w, h], color::expand(&samples, channels))) |
| 286 | } |
| 287 | |
| 288 | #[cfg(test)] |
| 289 | mod tests { |
| 290 | use super::*; |
| 291 | use image::codecs::gif::GifEncoder; |
| 292 | use image::{Delay, Rgba, RgbaImage}; |
| 293 | |
| 294 | fn dir() -> std::path::PathBuf { |
| 295 | let d = std::env::temp_dir().join(format!("remage-imgrs-{}", std::process::id())); |
| 296 | std::fs::create_dir_all(&d).unwrap(); |
| 297 | d |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn gif_animation_keeps_frames_and_delays() { |
| 302 | let path = dir().join("a.gif"); |
| 303 | { |
| 304 | let mut enc = GifEncoder::new(File::create(&path).unwrap()); |
| 305 | for (c, ms) in [(255, 50), (0, 0)] { |
| 306 | let buf = RgbaImage::from_pixel(4, 3, Rgba([c, 0, 0, 255])); |
| 307 | let f = image::Frame::from_parts(buf, 0, 0, Delay::from_numer_denom_ms(ms, 1)); |
| 308 | enc.encode_frame(f).unwrap(); |
| 309 | } |
| 310 | } |
| 311 | let img = decode_file(&path, ImageFormat::Gif, &Options::default()).unwrap(); |
| 312 | assert!(img.animated); |
| 313 | assert_eq!(img.size, [4, 3]); |
| 314 | assert_eq!( |
| 315 | img.delays(), |
| 316 | [Duration::from_millis(50), DEFAULT_DELAY], |
| 317 | "a zero delay plays at the browser pace" |
| 318 | ); |
| 319 | assert_eq!(img.frame(1)[0], 0); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn jpeg_fast_path() { |
| 324 | use image::codecs::jpeg::JpegEncoder; |
| 325 | let dir = dir(); |
| 326 | // Grey and colour both come out as opaque RGBA. |
| 327 | let grey = dir.join("grey.jpg"); |
| 328 | let buf = image::GrayImage::from_pixel(16, 8, image::Luma([200])); |
| 329 | JpegEncoder::new_with_quality(File::create(&grey).unwrap(), 95) |
| 330 | .encode_image(&buf) |
| 331 | .unwrap(); |
| 332 | let img = decode_jpeg(&grey, &Options::default()).unwrap().unwrap(); |
| 333 | assert_eq!((img.size, img.pixels().len()), ([16, 8], 16 * 8 * 4)); |
| 334 | let p = &img.pixels()[..4]; |
| 335 | assert!(p[0].abs_diff(200) <= 2 && p[0] == p[1] && p[1] == p[2] && p[3] == 255); |
| 336 | |
| 337 | let rgb = dir.join("rgb.jpg"); |
| 338 | let buf = image::RgbImage::from_pixel(16, 8, image::Rgb([250, 10, 10])); |
| 339 | JpegEncoder::new_with_quality(File::create(&rgb).unwrap(), 95) |
| 340 | .encode_image(&buf) |
| 341 | .unwrap(); |
| 342 | let img = decode_file(&rgb, ImageFormat::Jpeg, &Options::default()).unwrap(); |
| 343 | let p = &img.pixels()[..4]; |
| 344 | assert!(p[0] > 235 && p[1] < 30 && p[2] < 30 && p[3] == 255, "{p:?}"); |
| 345 | // The same pixels as the long way round. |
| 346 | let slow = decode_reader( |
| 347 | BufReader::new(File::open(&rgb).unwrap()), |
| 348 | ImageFormat::Jpeg, |
| 349 | &Options::default(), |
| 350 | ) |
| 351 | .unwrap(); |
| 352 | assert_eq!(img.pixels(), slow.pixels()); |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn multi_page_tiff() { |
| 357 | use tiff::encoder::{TiffEncoder, colortype}; |
| 358 | let path = dir().join("pages.tif"); |
| 359 | { |
| 360 | let mut enc = TiffEncoder::new(File::create(&path).unwrap()).unwrap(); |
| 361 | enc.write_image::<colortype::RGB8>(2, 2, &[10; 12]).unwrap(); |
| 362 | enc.write_image::<colortype::Gray8>(2, 2, &[200; 4]) |
| 363 | .unwrap(); |
| 364 | enc.write_image::<colortype::Gray8>(3, 1, &[0; 3]).unwrap(); |
| 365 | } |
| 366 | let img = decode_file(&path, ImageFormat::Tiff, &Options::default()).unwrap(); |
| 367 | assert!(!img.animated); |
| 368 | assert_eq!(img.frames.len(), 2, "the odd-sized page is skipped"); |
| 369 | assert_eq!(&img.frame(1)[..4], [200, 200, 200, 255]); |
| 370 | } |
| 371 | |
| 372 | #[test] |
| 373 | fn float_images_are_tonemapped() { |
| 374 | let path = dir().join("hdr.exr"); |
| 375 | let buf = image::Rgb32FImage::from_pixel(2, 1, image::Rgb([4.0, 0.5, 0.0])); |
| 376 | DynamicImage::ImageRgb32F(buf).save(&path).unwrap(); |
| 377 | let img = decode_file(&path, ImageFormat::OpenExr, &Options::default()).unwrap(); |
| 378 | assert_eq!(img.format, "EXR"); |
| 379 | let px = img.pixels(); |
| 380 | assert!(px[0] > px[1] && px[0] < 255 && px[2] == 0, "{px:?}"); |
| 381 | } |
| 382 | } |