Krolyxon
Commit a44bdf7

Branches

Tags

Refs →

remagesrcdecodemod.rs

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