Krolyxon
Commit 4654f72

Branches

Tags

Refs →

remagesrcdecodemod.rs

553 lines · 18.1 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;
23#[cfg(feature = "svg")]
24use std::sync::Arc;
25use std::sync::LazyLock;
26use std::time::Duration;
27
28use anyhow::anyhow;
29use image::ImageFormat;
30
31pub use external::set_decoders;
32
33/// Refuse single images that would need more than this to decode.
34const MAX_ALLOC: u64 = 4 << 30;
35/// Animations and multi-page files stop adding frames past this many bytes.
36const MAX_FRAMES_BYTES: usize = 1 << 30;
37
38/// One frame of an animation or one page of a document, `size` RGBA8 pixels.
39pub 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.
46pub 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)]
63pub 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
71impl 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
103impl 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
138impl 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)]
150pub 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
159impl 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")]
170const 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"))]
176const RAW_EXTENSIONS: &[&str] = &[];
177
178static 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
200fn 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.
207pub 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)]
214enum 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.
224fn 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
235fn 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.
243fn 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
292fn 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.
299pub 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
310fn 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.
332pub 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)]
352mod 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}