| 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 | let mut img = decode_reader(open()?, format, opts)?; |
| 72 | if format == ImageFormat::Tiff { |
| 73 | add_tiff_pages(path, &mut img, opts); |
| 74 | } |
| 75 | Ok(img) |
| 76 | } |
| 77 | |
| 78 | /// A still image from any reader: ICC to sRGB, float tone-mapped, oriented upright. |
| 79 | pub fn decode_reader<R: BufRead + Seek>( |
| 80 | reader: R, |
| 81 | format: ImageFormat, |
| 82 | opts: &Options, |
| 83 | ) -> anyhow::Result<DecodedImage> { |
| 84 | let mut reader = ImageReader::with_format(reader, format); |
| 85 | reader.limits(limits()); |
| 86 | let mut decoder = reader.into_decoder()?; |
| 87 | let orientation = if opts.auto_orient { |
| 88 | decoder.orientation().unwrap_or(Orientation::NoTransforms) |
| 89 | } else { |
| 90 | Orientation::NoTransforms |
| 91 | }; |
| 92 | let icc = if opts.color { |
| 93 | decoder.icc_profile().ok().flatten() |
| 94 | } else { |
| 95 | None |
| 96 | }; |
| 97 | let img = DynamicImage::from_decoder(decoder)?; |
| 98 | let float = color::is_float(&img); |
| 99 | let (size, mut pixels) = color::to_rgba8(img); |
| 100 | // Float data is linear scene light; an embedded profile doesn't describe the 8-bit result. |
| 101 | if !float && let Some(t) = icc.as_deref().and_then(ToSrgb::from_icc) { |
| 102 | t.apply(&mut pixels); |
| 103 | } |
| 104 | let (size, pixels) = color::orient(size, pixels, orientation); |
| 105 | Ok(DecodedImage::still(size, pixels, label(format))) |
| 106 | } |
| 107 | |
| 108 | /// Decode from memory, guessing the format from the bytes (external decoders' output). |
| 109 | pub fn decode_bytes(bytes: &[u8], opts: &Options) -> anyhow::Result<DecodedImage> { |
| 110 | let format = image::guess_format(bytes).context("unrecognised output")?; |
| 111 | decode_reader(Cursor::new(bytes), format, opts) |
| 112 | } |
| 113 | |
| 114 | /// All frames of an animation, or `None` when it has only one (decode it as a still, which |
| 115 | /// keeps its orientation). |
| 116 | fn animation<'a>( |
| 117 | decoder: impl AnimationDecoder<'a>, |
| 118 | icc: Option<Vec<u8>>, |
| 119 | format: ImageFormat, |
| 120 | ) -> anyhow::Result<Option<DecodedImage>> { |
| 121 | let transform = icc.as_deref().and_then(ToSrgb::from_icc); |
| 122 | let mut frames = Vec::new(); |
| 123 | let mut size = [0, 0]; |
| 124 | let mut bytes = 0; |
| 125 | for frame in decoder.into_frames() { |
| 126 | let frame = frame?; |
| 127 | let (num, den) = frame.delay().numer_denom_ms(); |
| 128 | let ms = num.checked_div(den).unwrap_or(0); |
| 129 | let delay = match Duration::from_millis(ms as u64) { |
| 130 | d if d < MIN_DELAY => DEFAULT_DELAY, |
| 131 | d => d, |
| 132 | }; |
| 133 | let buf = frame.into_buffer(); |
| 134 | size = [buf.width(), buf.height()]; |
| 135 | let mut pixels = buf.into_raw(); |
| 136 | if let Some(t) = &transform { |
| 137 | t.apply(&mut pixels); |
| 138 | } |
| 139 | bytes += pixels.len(); |
| 140 | frames.push(Frame { pixels, delay }); |
| 141 | if bytes > MAX_FRAMES_BYTES { |
| 142 | log::warn!("animation truncated at {} frames", frames.len()); |
| 143 | break; |
| 144 | } |
| 145 | } |
| 146 | match frames.len() { |
| 147 | 0 => Err(anyhow!("no frames")), |
| 148 | 1 => Ok(None), |
| 149 | n => { |
| 150 | log::debug!("{n} frames"); |
| 151 | Ok(Some(DecodedImage { |
| 152 | size, |
| 153 | frames, |
| 154 | animated: true, |
| 155 | format: label(format), |
| 156 | source_size: size, |
| 157 | refine: None, |
| 158 | })) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Pages after the first, as extra (untimed) frames. Pages of another size are skipped. |
| 164 | fn add_tiff_pages(path: &Path, img: &mut DecodedImage, opts: &Options) { |
| 165 | let Ok(file) = File::open(path) else { return }; |
| 166 | let Ok(mut d) = tiff::decoder::Decoder::new(BufReader::new(file)) else { |
| 167 | return; |
| 168 | }; |
| 169 | let mut bytes = img.bytes(); |
| 170 | while d.more_images() { |
| 171 | if d.next_image().is_err() { |
| 172 | break; |
| 173 | } |
| 174 | match tiff_page(&mut d) { |
| 175 | Ok((size, pixels)) => { |
| 176 | // `image` rotated the first page; rotate the rest the same way. |
| 177 | let o = orientation_of(&mut d, opts); |
| 178 | let (size, pixels) = color::orient(size, pixels, o); |
| 179 | if size != img.size { |
| 180 | log::debug!("TIFF page of another size skipped"); |
| 181 | continue; |
| 182 | } |
| 183 | bytes += pixels.len(); |
| 184 | img.frames.push(Frame { |
| 185 | pixels, |
| 186 | delay: Duration::ZERO, |
| 187 | }); |
| 188 | } |
| 189 | Err(e) => log::debug!("TIFF page skipped: {e:#}"), |
| 190 | } |
| 191 | if bytes > MAX_FRAMES_BYTES { |
| 192 | log::warn!("multi-page TIFF truncated at {} pages", img.frames.len()); |
| 193 | break; |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | fn orientation_of<R: std::io::Read + Seek>( |
| 199 | d: &mut tiff::decoder::Decoder<R>, |
| 200 | opts: &Options, |
| 201 | ) -> Orientation { |
| 202 | if !opts.auto_orient { |
| 203 | return Orientation::NoTransforms; |
| 204 | } |
| 205 | d.find_tag_unsigned::<u8>(tiff::tags::Tag::Orientation) |
| 206 | .ok() |
| 207 | .flatten() |
| 208 | .and_then(Orientation::from_exif) |
| 209 | .unwrap_or(Orientation::NoTransforms) |
| 210 | } |
| 211 | |
| 212 | fn tiff_page<R: std::io::Read + Seek>( |
| 213 | d: &mut tiff::decoder::Decoder<R>, |
| 214 | ) -> anyhow::Result<([u32; 2], Vec<u8>)> { |
| 215 | use tiff::ColorType as C; |
| 216 | use tiff::decoder::DecodingResult as D; |
| 217 | let (w, h) = d.dimensions()?; |
| 218 | let channels = match d.colortype()? { |
| 219 | C::Gray(8 | 16) => 1, |
| 220 | C::GrayA(8 | 16) => 2, |
| 221 | C::RGB(8 | 16) => 3, |
| 222 | C::RGBA(8 | 16) => 4, |
| 223 | other => return Err(anyhow!("unsupported colour type {other:?}")), |
| 224 | }; |
| 225 | let samples: Vec<u8> = match d.read_image()? { |
| 226 | D::U8(v) => v, |
| 227 | D::U16(v) => v.into_iter().map(|s| (s >> 8) as u8).collect(), |
| 228 | _ => return Err(anyhow!("unsupported sample format")), |
| 229 | }; |
| 230 | if samples.len() < (w * h) as usize * channels { |
| 231 | return Err(anyhow!("short page")); |
| 232 | } |
| 233 | Ok(([w, h], color::expand(&samples, channels))) |
| 234 | } |
| 235 | |
| 236 | #[cfg(test)] |
| 237 | mod tests { |
| 238 | use super::*; |
| 239 | use image::codecs::gif::GifEncoder; |
| 240 | use image::{Delay, Rgba, RgbaImage}; |
| 241 | |
| 242 | fn dir() -> std::path::PathBuf { |
| 243 | let d = std::env::temp_dir().join(format!("remage-imgrs-{}", std::process::id())); |
| 244 | std::fs::create_dir_all(&d).unwrap(); |
| 245 | d |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn gif_animation_keeps_frames_and_delays() { |
| 250 | let path = dir().join("a.gif"); |
| 251 | { |
| 252 | let mut enc = GifEncoder::new(File::create(&path).unwrap()); |
| 253 | for (c, ms) in [(255, 50), (0, 0)] { |
| 254 | let buf = RgbaImage::from_pixel(4, 3, Rgba([c, 0, 0, 255])); |
| 255 | let f = image::Frame::from_parts(buf, 0, 0, Delay::from_numer_denom_ms(ms, 1)); |
| 256 | enc.encode_frame(f).unwrap(); |
| 257 | } |
| 258 | } |
| 259 | let img = decode_file(&path, ImageFormat::Gif, &Options::default()).unwrap(); |
| 260 | assert!(img.animated); |
| 261 | assert_eq!(img.size, [4, 3]); |
| 262 | assert_eq!( |
| 263 | img.delays(), |
| 264 | [Duration::from_millis(50), DEFAULT_DELAY], |
| 265 | "a zero delay plays at the browser pace" |
| 266 | ); |
| 267 | assert_eq!(img.frame(1)[0], 0); |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn multi_page_tiff() { |
| 272 | use tiff::encoder::{TiffEncoder, colortype}; |
| 273 | let path = dir().join("pages.tif"); |
| 274 | { |
| 275 | let mut enc = TiffEncoder::new(File::create(&path).unwrap()).unwrap(); |
| 276 | enc.write_image::<colortype::RGB8>(2, 2, &[10; 12]).unwrap(); |
| 277 | enc.write_image::<colortype::Gray8>(2, 2, &[200; 4]) |
| 278 | .unwrap(); |
| 279 | enc.write_image::<colortype::Gray8>(3, 1, &[0; 3]).unwrap(); |
| 280 | } |
| 281 | let img = decode_file(&path, ImageFormat::Tiff, &Options::default()).unwrap(); |
| 282 | assert!(!img.animated); |
| 283 | assert_eq!(img.frames.len(), 2, "the odd-sized page is skipped"); |
| 284 | assert_eq!(&img.frame(1)[..4], [200, 200, 200, 255]); |
| 285 | } |
| 286 | |
| 287 | #[test] |
| 288 | fn float_images_are_tonemapped() { |
| 289 | let path = dir().join("hdr.exr"); |
| 290 | let buf = image::Rgb32FImage::from_pixel(2, 1, image::Rgb([4.0, 0.5, 0.0])); |
| 291 | DynamicImage::ImageRgb32F(buf).save(&path).unwrap(); |
| 292 | let img = decode_file(&path, ImageFormat::OpenExr, &Options::default()).unwrap(); |
| 293 | assert_eq!(img.format, "EXR"); |
| 294 | let px = img.pixels(); |
| 295 | assert!(px[0] > px[1] && px[0] < 255 && px[2] == 0, "{px:?}"); |
| 296 | } |
| 297 | } |