| 1 | //! Format sniffing and dispatch to the decoder backends. |
| 2 | //! |
| 3 | //! Every backend returns straight-alpha RGBA8 in sRGB, upright: ICC/CICP colour is converted, |
| 4 | //! float images are tone-mapped and EXIF orientation is applied here, so nothing downstream |
| 5 | //! cares where pixels came from. |
| 6 | |
| 7 | pub mod color; |
| 8 | mod external; |
| 9 | #[cfg(feature = "heif")] |
| 10 | mod heif; |
| 11 | mod image_rs; |
| 12 | #[cfg(feature = "jxl")] |
| 13 | mod jxl; |
| 14 | pub mod meta; |
| 15 | #[cfg(feature = "raw")] |
| 16 | mod raw; |
| 17 | #[cfg(feature = "svg")] |
| 18 | mod svg; |
| 19 | |
| 20 | use std::fs::File; |
| 21 | use std::io::Read; |
| 22 | use std::path::Path; |
| 23 | #[cfg(feature = "svg")] |
| 24 | use std::sync::Arc; |
| 25 | use std::sync::LazyLock; |
| 26 | use std::time::Duration; |
| 27 | |
| 28 | use anyhow::anyhow; |
| 29 | use image::ImageFormat; |
| 30 | |
| 31 | pub use external::set_decoders; |
| 32 | |
| 33 | /// Refuse single images that would need more than this to decode. |
| 34 | const MAX_ALLOC: u64 = 4 << 30; |
| 35 | /// Animations and multi-page files stop adding frames past this many bytes. |
| 36 | const MAX_FRAMES_BYTES: usize = 1 << 30; |
| 37 | |
| 38 | /// One frame of an animation or one page of a document, `size` RGBA8 pixels. |
| 39 | pub struct Frame { |
| 40 | pub pixels: Vec<u8>, |
| 41 | /// How long an animation frame shows; zero for pages. |
| 42 | pub delay: Duration, |
| 43 | } |
| 44 | |
| 45 | /// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload. |
| 46 | pub struct DecodedImage { |
| 47 | /// Pixel size of every frame. |
| 48 | pub size: [u32; 2], |
| 49 | /// At least one. |
| 50 | pub frames: Vec<Frame>, |
| 51 | /// Frames are timed animation rather than pages. |
| 52 | pub animated: bool, |
| 53 | pub format: &'static str, |
| 54 | /// The size the view works in: the image's own pixels. Scalable or previewed sources |
| 55 | /// (SVG, RAW) may hold fewer or more pixels than this. |
| 56 | pub source_size: [u32; 2], |
| 57 | /// A sharper version can be made on demand (SVG re-raster, full RAW develop). |
| 58 | pub refine: Option<Refine>, |
| 59 | } |
| 60 | |
| 61 | /// How to make a sharper version of an image. |
| 62 | #[derive(Clone)] |
| 63 | pub enum Refine { |
| 64 | #[cfg(feature = "svg")] |
| 65 | Svg(Arc<svg::Svg>), |
| 66 | /// The embedded preview is showing; develop the raw data in full. |
| 67 | #[cfg(feature = "raw")] |
| 68 | Raw, |
| 69 | } |
| 70 | |
| 71 | impl Refine { |
| 72 | /// Make a version whose longer side is about `long` pixels. |
| 73 | pub fn run(&self, path: &Path, long: u32, opts: &Options) -> anyhow::Result<DecodedImage> { |
| 74 | let _ = (path, long, opts); |
| 75 | match self { |
| 76 | #[cfg(feature = "svg")] |
| 77 | Refine::Svg(s) => Ok(s.rasterize(long)), |
| 78 | #[cfg(feature = "raw")] |
| 79 | Refine::Raw => raw::develop(path, opts), |
| 80 | #[allow(unreachable_patterns)] |
| 81 | _ => unreachable!(), |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// Whether a texture of `have` pixels on its longer side should be remade for `want`. |
| 86 | pub fn wants(&self, have: u32, want: u32) -> Option<u32> { |
| 87 | let _ = (have, want); |
| 88 | match self { |
| 89 | #[cfg(feature = "svg")] |
| 90 | Refine::Svg(_) => { |
| 91 | let want = want.clamp(16, svg::MAX_RASTER); |
| 92 | // Sharper when zoomed in; smaller again when zoomed far out. |
| 93 | (want > have + have / 8 || want < have / 3).then_some(want) |
| 94 | } |
| 95 | #[cfg(feature = "raw")] |
| 96 | Refine::Raw => (want > have).then_some(u32::MAX), |
| 97 | #[allow(unreachable_patterns)] |
| 98 | _ => None, |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | impl DecodedImage { |
| 104 | pub fn still(size: [u32; 2], pixels: Vec<u8>, format: &'static str) -> Self { |
| 105 | Self { |
| 106 | size, |
| 107 | frames: vec![Frame { |
| 108 | pixels, |
| 109 | delay: Duration::ZERO, |
| 110 | }], |
| 111 | animated: false, |
| 112 | format, |
| 113 | source_size: size, |
| 114 | refine: None, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | pub fn bytes(&self) -> usize { |
| 119 | self.frames.iter().map(|f| f.pixels.len()).sum() |
| 120 | } |
| 121 | |
| 122 | /// The first frame's pixels. |
| 123 | #[cfg(test)] |
| 124 | pub fn pixels(&self) -> &[u8] { |
| 125 | &self.frames[0].pixels |
| 126 | } |
| 127 | |
| 128 | pub fn frame(&self, i: usize) -> &[u8] { |
| 129 | &self.frames[i.min(self.frames.len() - 1)].pixels |
| 130 | } |
| 131 | |
| 132 | /// Frame delays when there is more than one frame. |
| 133 | pub fn delays(&self) -> Vec<Duration> { |
| 134 | self.frames.iter().map(|f| f.delay).collect() |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | impl std::fmt::Debug for DecodedImage { |
| 139 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 140 | f.debug_struct("DecodedImage") |
| 141 | .field("size", &self.size) |
| 142 | .field("frames", &self.frames.len()) |
| 143 | .field("format", &self.format) |
| 144 | .finish_non_exhaustive() |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// How to decode. |
| 149 | #[derive(Clone, Copy, Debug)] |
| 150 | pub struct Options { |
| 151 | /// About how many pixels the longer side needs, for sources that can be rendered at any |
| 152 | /// size (SVG) or offer a preview (RAW). |
| 153 | #[cfg_attr(not(any(feature = "svg", feature = "raw")), allow(dead_code))] |
| 154 | pub target: u32, |
| 155 | pub auto_orient: bool, |
| 156 | pub color: bool, |
| 157 | } |
| 158 | |
| 159 | impl Default for Options { |
| 160 | fn default() -> Self { |
| 161 | Self { |
| 162 | target: 2048, |
| 163 | auto_orient: true, |
| 164 | color: true, |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | #[cfg(feature = "raw")] |
| 170 | const RAW_EXTENSIONS: &[&str] = &[ |
| 171 | "3fr", "ari", "arw", "cr2", "cr3", "crw", "dcr", "dcs", "dng", "erf", "iiq", "kdc", "mef", |
| 172 | "mos", "mrw", "nef", "nrw", "orf", "ori", "pef", "raf", "raw", "rw2", "rwl", "sr2", "srf", |
| 173 | "srw", "x3f", |
| 174 | ]; |
| 175 | #[cfg(not(feature = "raw"))] |
| 176 | const RAW_EXTENSIONS: &[&str] = &[]; |
| 177 | |
| 178 | static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| { |
| 179 | let mut v: Vec<&'static str> = ImageFormat::all() |
| 180 | .filter(ImageFormat::reading_enabled) |
| 181 | .flat_map(|f| f.extensions_str().iter().copied()) |
| 182 | .chain(["apng", "jfif"]) |
| 183 | .collect(); |
| 184 | if cfg!(feature = "avif") { |
| 185 | v.push("avif"); |
| 186 | } |
| 187 | if cfg!(feature = "jxl") { |
| 188 | v.push("jxl"); |
| 189 | } |
| 190 | if cfg!(feature = "heif") { |
| 191 | v.extend(["heic", "heif", "hif"]); |
| 192 | } |
| 193 | if cfg!(feature = "svg") { |
| 194 | v.extend(["svg", "svgz"]); |
| 195 | } |
| 196 | v.extend(RAW_EXTENSIONS); |
| 197 | v |
| 198 | }); |
| 199 | |
| 200 | fn extension(path: &Path) -> Option<String> { |
| 201 | path.extension() |
| 202 | .and_then(|e| e.to_str()) |
| 203 | .map(str::to_ascii_lowercase) |
| 204 | } |
| 205 | |
| 206 | /// Whether the extension alone (or an external decoder) says we can decode this file. |
| 207 | pub fn is_known_extension(path: &Path) -> bool { |
| 208 | extension(path).is_some_and(|e| EXTENSIONS.contains(&e.as_str())) |
| 209 | || external::find(path).is_some() |
| 210 | } |
| 211 | |
| 212 | /// Which backend decodes a file. |
| 213 | #[derive(Clone, Debug, PartialEq)] |
| 214 | enum Kind { |
| 215 | Image(ImageFormat), |
| 216 | Jxl, |
| 217 | Heif, |
| 218 | Svg, |
| 219 | Raw, |
| 220 | External(crate::config::Decoder), |
| 221 | } |
| 222 | |
| 223 | /// The ISO-BMFF `ftyp` brands, major first. |
| 224 | fn ftyp_brands(head: &[u8]) -> Vec<&[u8]> { |
| 225 | if head.len() < 16 || &head[4..8] != b"ftyp" { |
| 226 | return Vec::new(); |
| 227 | } |
| 228 | let len = u32::from_be_bytes([head[0], head[1], head[2], head[3]]) as usize; |
| 229 | let end = len.clamp(16, head.len()); |
| 230 | let mut brands = vec![&head[8..12]]; |
| 231 | brands.extend(head[16..end].chunks_exact(4)); |
| 232 | brands |
| 233 | } |
| 234 | |
| 235 | fn looks_like_svg(head: &[u8]) -> bool { |
| 236 | let text = String::from_utf8_lossy(head); |
| 237 | let t = text.trim_start_matches('\u{feff}').trim_start(); |
| 238 | (t.starts_with("<?xml") || t.starts_with("<svg") || t.starts_with("<!--")) |
| 239 | && text.contains("<svg") |
| 240 | } |
| 241 | |
| 242 | /// Magic bytes first, the extension second. External decoders configured for a file win. |
| 243 | fn detect(path: &Path, head: &[u8]) -> Option<Kind> { |
| 244 | if let Some(d) = external::find(path) { |
| 245 | return Some(Kind::External(d)); |
| 246 | } |
| 247 | let ext = extension(path).unwrap_or_default(); |
| 248 | let jxl = head.starts_with(&[0xFF, 0x0A]) || head.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n"); |
| 249 | if jxl && cfg!(feature = "jxl") { |
| 250 | return Some(Kind::Jxl); |
| 251 | } |
| 252 | let brands = ftyp_brands(head); |
| 253 | if !brands.is_empty() { |
| 254 | let has = |b: &[u8]| brands.contains(&b); |
| 255 | if has(b"crx ") && cfg!(feature = "raw") { |
| 256 | return Some(Kind::Raw); |
| 257 | } |
| 258 | if has(b"avif") || has(b"avis") { |
| 259 | return Some(Kind::Image(ImageFormat::Avif)); |
| 260 | } |
| 261 | let heif = [ |
| 262 | b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"mif1", b"msf1", |
| 263 | ]; |
| 264 | if heif.iter().any(|b| has(*b)) && cfg!(feature = "heif") { |
| 265 | return Some(Kind::Heif); |
| 266 | } |
| 267 | } |
| 268 | // Most raw formats are TIFF inside, so the extension decides before the magic does. |
| 269 | if RAW_EXTENSIONS.contains(&ext.as_str()) { |
| 270 | return Some(Kind::Raw); |
| 271 | } |
| 272 | if cfg!(feature = "svg") |
| 273 | && (looks_like_svg(head) || (ext == "svgz" && head.starts_with(&[0x1F, 0x8B]))) |
| 274 | { |
| 275 | return Some(Kind::Svg); |
| 276 | } |
| 277 | if let Ok(f) = image::guess_format(head) |
| 278 | && f.reading_enabled() |
| 279 | { |
| 280 | return Some(Kind::Image(f)); |
| 281 | } |
| 282 | match ext.as_str() { |
| 283 | "jxl" if cfg!(feature = "jxl") => Some(Kind::Jxl), |
| 284 | "heic" | "heif" | "hif" if cfg!(feature = "heif") => Some(Kind::Heif), |
| 285 | "svg" | "svgz" if cfg!(feature = "svg") => Some(Kind::Svg), |
| 286 | _ => ImageFormat::from_extension(&ext) |
| 287 | .filter(ImageFormat::reading_enabled) |
| 288 | .map(Kind::Image), |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | fn head(path: &Path) -> std::io::Result<Vec<u8>> { |
| 293 | let mut buf = Vec::with_capacity(1024); |
| 294 | File::open(path)?.take(1024).read_to_end(&mut buf)?; |
| 295 | Ok(buf) |
| 296 | } |
| 297 | |
| 298 | /// Whether a file with an unknown extension is an image we can decode. |
| 299 | pub fn sniff(path: &Path) -> bool { |
| 300 | let Ok(head) = head(path) else { return false }; |
| 301 | // An extension guess alone is not proof; require the content to say so. |
| 302 | match detect(path, &head) { |
| 303 | Some(Kind::Image(_)) => image::guess_format(&head).is_ok(), |
| 304 | Some(Kind::Raw) => false, |
| 305 | Some(_) => true, |
| 306 | None => false, |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | fn label(format: ImageFormat) -> &'static str { |
| 311 | match format { |
| 312 | ImageFormat::Png => "PNG", |
| 313 | ImageFormat::Jpeg => "JPEG", |
| 314 | ImageFormat::Gif => "GIF", |
| 315 | ImageFormat::WebP => "WEBP", |
| 316 | ImageFormat::Pnm => "PNM", |
| 317 | ImageFormat::Tiff => "TIFF", |
| 318 | ImageFormat::Tga => "TGA", |
| 319 | ImageFormat::Dds => "DDS", |
| 320 | ImageFormat::Bmp => "BMP", |
| 321 | ImageFormat::Ico => "ICO", |
| 322 | ImageFormat::Hdr => "HDR", |
| 323 | ImageFormat::OpenExr => "EXR", |
| 324 | ImageFormat::Farbfeld => "FARBFELD", |
| 325 | ImageFormat::Avif => "AVIF", |
| 326 | ImageFormat::Qoi => "QOI", |
| 327 | _ => "IMAGE", |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | /// Decode `path` in full: every frame, colour-converted and upright. |
| 332 | pub fn decode(path: &Path, opts: &Options) -> anyhow::Result<DecodedImage> { |
| 333 | let head = head(path)?; |
| 334 | let kind = detect(path, &head).ok_or_else(|| anyhow!("unknown format"))?; |
| 335 | match kind { |
| 336 | Kind::Image(f) => image_rs::decode_file(path, f, opts), |
| 337 | #[cfg(feature = "jxl")] |
| 338 | Kind::Jxl => jxl::decode(path, opts), |
| 339 | #[cfg(feature = "heif")] |
| 340 | Kind::Heif => heif::decode(path, opts), |
| 341 | #[cfg(feature = "svg")] |
| 342 | Kind::Svg => svg::decode(path, opts), |
| 343 | #[cfg(feature = "raw")] |
| 344 | Kind::Raw => raw::decode(path, opts), |
| 345 | Kind::External(d) => external::decode(path, &d, opts), |
| 346 | #[allow(unreachable_patterns)] |
| 347 | _ => Err(anyhow!("support for this format is not compiled in")), |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | #[cfg(test)] |
| 352 | mod tests { |
| 353 | use super::*; |
| 354 | |
| 355 | #[test] |
| 356 | fn known_extensions() { |
| 357 | assert!(is_known_extension(Path::new("a/b.JPG"))); |
| 358 | assert!(is_known_extension(Path::new("b.png"))); |
| 359 | assert!(!is_known_extension(Path::new("b.txt"))); |
| 360 | assert!(!is_known_extension(Path::new("png"))); |
| 361 | #[cfg(feature = "jxl")] |
| 362 | assert!(is_known_extension(Path::new("a.jxl"))); |
| 363 | #[cfg(feature = "raw")] |
| 364 | assert!(is_known_extension(Path::new("a.NEF"))); |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn detection_prefers_magic() { |
| 369 | let p = Path::new("x.png"); |
| 370 | assert_eq!( |
| 371 | detect(p, b"\xFF\xD8\xFF\xE0"), |
| 372 | Some(Kind::Image(ImageFormat::Jpeg)) |
| 373 | ); |
| 374 | let avif = b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"; |
| 375 | assert_eq!(detect(p, avif), Some(Kind::Image(ImageFormat::Avif))); |
| 376 | #[cfg(feature = "heif")] |
| 377 | assert_eq!( |
| 378 | detect(p, b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), |
| 379 | Some(Kind::Heif) |
| 380 | ); |
| 381 | #[cfg(feature = "jxl")] |
| 382 | assert_eq!(detect(p, b"\xFF\x0A\x00"), Some(Kind::Jxl)); |
| 383 | #[cfg(feature = "svg")] |
| 384 | assert_eq!( |
| 385 | detect(p, b"<?xml version=\"1.0\"?>\n<svg xmlns=\"x\">"), |
| 386 | Some(Kind::Svg) |
| 387 | ); |
| 388 | #[cfg(feature = "raw")] |
| 389 | assert_eq!(detect(Path::new("a.nef"), b"MM\0*"), Some(Kind::Raw)); |
| 390 | assert_eq!( |
| 391 | detect(Path::new("a.tif"), b"MM\0*"), |
| 392 | Some(Kind::Image(ImageFormat::Tiff)) |
| 393 | ); |
| 394 | assert_eq!(detect(Path::new("a.txt"), b"hello"), None); |
| 395 | } |
| 396 | |
| 397 | #[test] |
| 398 | fn decodes_png_without_extension() { |
| 399 | let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id())); |
| 400 | std::fs::create_dir_all(&dir).unwrap(); |
| 401 | let path = dir.join("noext"); |
| 402 | image::RgbaImage::from_pixel(64, 32, image::Rgba([255, 0, 0, 128])) |
| 403 | .save_with_format(&path, ImageFormat::Png) |
| 404 | .unwrap(); |
| 405 | assert!(sniff(&path)); |
| 406 | let img = decode(&path, &Options::default()).unwrap(); |
| 407 | assert_eq!(img.format, "PNG"); |
| 408 | assert_eq!(img.source_size, [64, 32]); |
| 409 | assert_eq!(img.size, [64, 32]); |
| 410 | assert_eq!(img.pixels().len(), 64 * 32 * 4); |
| 411 | std::fs::remove_dir_all(&dir).unwrap(); |
| 412 | } |
| 413 | |
| 414 | /// Decoder tests against `tests/fixtures` (see `make.sh` there). |
| 415 | mod fixtures { |
| 416 | use super::*; |
| 417 | |
| 418 | fn fixture(name: &str) -> std::path::PathBuf { |
| 419 | Path::new(env!("CARGO_MANIFEST_DIR")) |
| 420 | .join("tests/fixtures") |
| 421 | .join(name) |
| 422 | } |
| 423 | |
| 424 | fn load(name: &str) -> DecodedImage { |
| 425 | decode(&fixture(name), &Options::default()).unwrap_or_else(|e| panic!("{name}: {e:#}")) |
| 426 | } |
| 427 | |
| 428 | fn close(a: &[u8], b: [u8; 3], name: &str, what: &str) { |
| 429 | let ok = a[..3].iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 40); |
| 430 | assert!(ok, "{name}: {what} is {:?}, expected {b:?}", &a[..4]); |
| 431 | } |
| 432 | |
| 433 | /// Red, lime, blue and white quadrants, upright. |
| 434 | fn check_quadrants(img: &DecodedImage, frame: usize, name: &str) { |
| 435 | assert_eq!(img.size, [16, 12], "{name}"); |
| 436 | let px = img.frame(frame); |
| 437 | let at = |x: usize, y: usize| &px[(y * 16 + x) * 4..][..4]; |
| 438 | close(at(3, 3), [255, 0, 0], name, "top left"); |
| 439 | close(at(12, 3), [0, 255, 0], name, "top right"); |
| 440 | close(at(3, 9), [0, 0, 255], name, "bottom left"); |
| 441 | close(at(12, 9), [255, 255, 255], name, "bottom right"); |
| 442 | } |
| 443 | |
| 444 | #[test] |
| 445 | fn every_format_decodes_upright() { |
| 446 | let mut names = vec![ |
| 447 | "quadrants.png", |
| 448 | "quadrants.jpg", |
| 449 | "quadrants.bmp", |
| 450 | "quadrants.tga", |
| 451 | "quadrants.qoi", |
| 452 | "quadrants.ppm", |
| 453 | "quadrants.webp", |
| 454 | "quadrants.exr", |
| 455 | "quadrants.avif", |
| 456 | ]; |
| 457 | if cfg!(feature = "jxl") { |
| 458 | names.push("quadrants.jxl"); |
| 459 | } |
| 460 | if cfg!(feature = "heif") { |
| 461 | names.push("quadrants.heic"); |
| 462 | } |
| 463 | if cfg!(feature = "svg") { |
| 464 | names.extend(["drawing.svg", "drawing.svgz"]); |
| 465 | } |
| 466 | for name in names { |
| 467 | assert!(is_known_extension(&fixture(name)), "{name}"); |
| 468 | check_quadrants(&load(name), 0, name); |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | #[test] |
| 473 | fn exif_orientations() { |
| 474 | for i in 1..=8 { |
| 475 | let name = format!("orient-{i}.jpg"); |
| 476 | check_quadrants(&load(&name), 0, &name); |
| 477 | } |
| 478 | // With auto_orient off, a rotated file stays as stored (12×16). |
| 479 | let opts = Options { |
| 480 | auto_orient: false, |
| 481 | ..Options::default() |
| 482 | }; |
| 483 | let raw = decode(&fixture("orient-6.jpg"), &opts).unwrap(); |
| 484 | assert_eq!(raw.size, [12, 16]); |
| 485 | } |
| 486 | |
| 487 | #[test] |
| 488 | fn animations() { |
| 489 | let mut names = vec!["anim.gif", "anim.webp", "anim.png"]; |
| 490 | if cfg!(feature = "jxl") { |
| 491 | names.push("anim.jxl"); |
| 492 | } |
| 493 | for name in names { |
| 494 | let img = load(name); |
| 495 | assert!(img.animated, "{name}"); |
| 496 | assert_eq!(img.frames.len(), 3, "{name}"); |
| 497 | let ms: Vec<u128> = img.delays().iter().map(Duration::as_millis).collect(); |
| 498 | // ImageMagick writes APNG at a constant frame rate. |
| 499 | let want = if name == "anim.png" { |
| 500 | [50, 50, 50] |
| 501 | } else { |
| 502 | [50, 100, 150] |
| 503 | }; |
| 504 | assert_eq!(ms, want, "{name}"); |
| 505 | for (f, c) in [[255, 0, 0], [0, 255, 0], [0, 0, 255]] |
| 506 | .into_iter() |
| 507 | .enumerate() |
| 508 | { |
| 509 | close(img.frame(f), c, name, &format!("frame {f}")); |
| 510 | } |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn pages() { |
| 516 | let img = load("pages.tif"); |
| 517 | assert!(!img.animated); |
| 518 | assert_eq!(img.frames.len(), 3); |
| 519 | close(img.frame(2), [0, 0, 255], "pages.tif", "page 3"); |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn icc_profiles_convert_to_srgb() { |
| 524 | use image::ImageEncoder; |
| 525 | let icc = moxcms::ColorProfile::new_display_p3().encode().unwrap(); |
| 526 | let dir = std::env::temp_dir().join(format!("remage-icc-{}", std::process::id())); |
| 527 | std::fs::create_dir_all(&dir).unwrap(); |
| 528 | let path = dir.join("p3.png"); |
| 529 | let px = [40u8, 160, 90, 255].repeat(4); |
| 530 | { |
| 531 | let mut enc = |
| 532 | image::codecs::png::PngEncoder::new(std::fs::File::create(&path).unwrap()); |
| 533 | enc.set_icc_profile(icc).unwrap(); |
| 534 | enc.write_image(&px, 2, 2, image::ExtendedColorType::Rgba8) |
| 535 | .unwrap(); |
| 536 | } |
| 537 | let raw = decode( |
| 538 | &path, |
| 539 | &Options { |
| 540 | color: false, |
| 541 | ..Options::default() |
| 542 | }, |
| 543 | ) |
| 544 | .unwrap(); |
| 545 | assert_eq!(&raw.frame(0)[..4], [40, 160, 90, 255]); |
| 546 | let srgb = decode(&path, &Options::default()).unwrap(); |
| 547 | let p = &srgb.frame(0)[..4]; |
| 548 | // P3 green is more saturated than sRGB can say: red drops, green rises. |
| 549 | assert!(p[0] < 40 && p[1] > 160 && p[3] == 255, "{p:?}"); |
| 550 | std::fs::remove_dir_all(&dir).unwrap(); |
| 551 | } |
| 552 | } |
| 553 | } |