Krolyxon
Commit e3295f9

Branches

Tags

Refs →

remagesrcdecodemod.rs

549 lines · 18.0 KB · Rust

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