1use image::metadata::Orientation;
13use image::{DynamicImage, RgbaImage};
14use std::path::Path;
15
16use crate::thumbnail::ThumbError;
17
18const TYPES: [(&str, &str); 7] = [
21 ("avif", "public.avif"),
22 ("avifs", "public.avis"),
23 ("heic", "public.heic"),
24 ("hif", "public.heic"),
25 ("heif", "public.heif"),
26 ("heics", "public.heics"),
27 ("jxl", "public.jpeg-xl"),
28];
29
30pub fn type_of(ext: &str) -> Option<&'static str> {
32 TYPES.iter().find(|(e, _)| *e == ext).map(|(_, t)| *t)
33}
34
35pub fn handles(ext: &str) -> bool {
38 type_of(&ext.to_lowercase()).is_some()
39}
40
41pub fn available(ext: &str) -> bool {
44 type_of(&ext.to_lowercase()).is_some_and(|t| readable_types().iter().any(|r| r == t))
45}
46
47pub fn orientation(value: i64) -> Orientation {
50 u8::try_from(value).ok().and_then(Orientation::from_exif).unwrap_or(Orientation::NoTransforms)
51}
52
53pub fn delay_ms(unclamped: Option<f64>, clamped: Option<f64>) -> u32 {
57 let secs = unclamped.filter(|s| s.is_finite() && *s > 0.0).or(clamped).unwrap_or(0.0);
58 if secs.is_finite() && secs > 0.0 { (secs * 1000.0).round().min(f64::from(u32::MAX)) as u32 } else { 0 }
59}
60
61pub fn is_animated(count: usize, first_has_delay: bool) -> bool {
66 count >= 2 && first_has_delay
67}
68
69pub fn oriented_size(w: u32, h: u32, o: Orientation) -> (u32, u32) {
71 match o {
72 Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => (h, w),
73 _ => (w, h),
74 }
75}
76
77fn unpremultiply(rgba: &mut [u8]) {
79 for px in rgba.as_chunks_mut::<4>().0 {
80 let a = px[3];
81 if a != 0 && a != 255 {
82 for c in &mut px[..3] {
83 *c = ((u32::from(*c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
84 }
85 }
86 }
87}
88
89pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
91 imp::decode(path)
92}
93
94pub struct Animation {
97 inner: imp::Source,
98 size: (u32, u32),
99 delays: Vec<u32>,
100}
101
102impl Animation {
103 pub fn open(path: &Path) -> Result<Option<Animation>, String> {
105 let Some((inner, delays)) = imp::Source::open(path)? else { return Ok(None) };
106 let size = inner.size(0);
107 Ok(Some(Animation { inner, size, delays }))
108 }
109
110 pub fn size(&self) -> (u32, u32) {
112 self.size
113 }
114
115 pub fn delays(&self) -> &[u32] {
117 &self.delays
118 }
119
120 pub fn frame(&mut self, i: usize) -> Result<RgbaImage, String> {
122 self.inner.frame(i)
123 }
124}
125
126#[derive(Clone, Copy)]
128pub struct WriteFrame<'a> {
129 pub image: &'a RgbaImage,
130 pub orientation: u8,
132 pub delay: Option<f64>,
134}
135
136pub fn write(path: &Path, ext: &str, frames: &[WriteFrame]) -> Result<(), String> {
140 let uti = type_of(&ext.to_lowercase()).ok_or_else(|| format!("ImageIO doesn't handle .{ext}"))?;
141 imp::write(path, uti, frames)
142}
143
144pub fn decode_bytes_within(data: &[u8], max_side: u32) -> Result<RgbaImage, String> {
149 imp::decode_bytes_within(data, max_side.max(1))
150}
151
152fn readable_types() -> &'static [String] {
153 static TYPES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
154 TYPES.get_or_init(imp::readable_types)
155}
156
157#[cfg(target_os = "macos")]
158mod imp {
159 use super::{Orientation, Path, RgbaImage, ThumbError, delay_ms, is_animated, orientation, unpremultiply};
165 use image::DynamicImage;
166 use objc2::rc::Retained;
167 use objc2::runtime::AnyObject;
168 use objc2_core_foundation::{CFArray, CFData, CFDictionary, CFRetained, CFString, CFURL, CGPoint, CGRect, CGSize};
169 use objc2_core_graphics::{
170 CGBitmapContextCreate, CGBitmapContextCreateImage, CGColorSpace, CGContext, CGImage, CGImageAlphaInfo, CGImageByteOrderInfo,
171 kCGColorSpaceSRGB,
172 };
173 use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString};
174 use objc2_image_io::{CGImageDestination, CGImageSource};
175
176 fn as_ns(d: &CFDictionary) -> &NSDictionary<AnyObject, AnyObject> {
177 unsafe { &*(d as *const CFDictionary).cast::<NSDictionary<AnyObject, AnyObject>>() }
180 }
181
182 fn get(d: &NSDictionary<AnyObject, AnyObject>, key: &str) -> Option<Retained<AnyObject>> {
183 let key = NSString::from_str(key);
184 d.objectForKey(&key)
185 }
186
187 fn number(d: &NSDictionary<AnyObject, AnyObject>, key: &str) -> Option<f64> {
188 get(d, key)?.downcast::<NSNumber>().ok().map(|n| n.doubleValue())
189 }
190
191 fn frame_delay(props: &NSDictionary<AnyObject, AnyObject>) -> Option<u32> {
194 let mut found = None;
195 for key in props.allKeys() {
196 let Some(value) = props.objectForKey(&key) else { continue };
197 let Ok(sub) = value.downcast::<NSDictionary>() else { continue };
198 let sub: &NSDictionary<AnyObject, AnyObject> = unsafe { &*(Retained::as_ptr(&sub)).cast() };
200 let (unclamped, clamped) = (number(sub, "UnclampedDelayTime"), number(sub, "DelayTime"));
201 if unclamped.is_some() || clamped.is_some() {
202 found = Some(delay_ms(unclamped, clamped));
203 break;
204 }
205 }
206 found
207 }
208
209 fn open_source(path: &Path) -> Result<CFRetained<CGImageSource>, String> {
210 let url = CFURL::from_file_path(path).ok_or("not a file path")?;
211 unsafe { CGImageSource::with_url(&url, None) }.ok_or_else(|| "ImageIO can't read the file".to_string())
213 }
214
215 fn properties(src: &CGImageSource, i: usize) -> Option<CFRetained<CFDictionary>> {
216 unsafe { src.properties_at_index(i, None) }
218 }
219
220 fn orientation_at(src: &CGImageSource, i: usize) -> Orientation {
221 properties(src, i).and_then(|p| number(as_ns(&p), "Orientation")).map_or(Orientation::NoTransforms, |v| orientation(v as i64))
222 }
223
224 fn decode_at(src: &CGImageSource, i: usize) -> Result<RgbaImage, String> {
226 let image = unsafe { src.image_at_index(i, None) }.ok_or("ImageIO decoded no picture")?;
228 let mut img = draw(&image)?;
229 let o = orientation_at(src, i);
230 if o != Orientation::NoTransforms {
231 let mut d = DynamicImage::ImageRgba8(img);
232 d.apply_orientation(o);
233 img = d.into_rgba8();
234 }
235 Ok(img)
236 }
237
238 fn draw(image: &CGImage) -> Result<RgbaImage, String> {
240 let (w, h) = (CGImage::width(Some(image)), CGImage::height(Some(image)));
241 if w == 0 || h == 0 || w.saturating_mul(h) > 1 << 30 {
242 return Err(format!("a {w} x {h} picture can't be decoded"));
243 }
244 let mut rgba = vec![0u8; w * h * 4];
245 let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })).or_else(CGColorSpace::new_device_rgb).ok_or("no RGB colour space")?;
247 let info = CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0;
248 let ctx = unsafe { CGBitmapContextCreate(rgba.as_mut_ptr().cast(), w, h, 8, w * 4, Some(&space), info) }.ok_or("no bitmap context")?;
252 CGContext::draw_image(Some(&ctx), CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(w as f64, h as f64)), Some(image));
253 drop(ctx);
254 unpremultiply(&mut rgba);
255 RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or_else(|| "bad buffer".to_string())
256 }
257
258 pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
259 let src = open_source(path).map_err(ThumbError)?;
260 let primary = unsafe { src.primary_image_index() };
262 decode_at(&src, primary).map(DynamicImage::ImageRgba8).map_err(ThumbError)
263 }
264
265 pub fn decode_bytes_within(data: &[u8], max_side: u32) -> Result<RgbaImage, String> {
266 let cf = CFData::from_bytes(data);
267 let src = unsafe { CGImageSource::with_data(&cf, None) }.ok_or("ImageIO can't read the data")?;
269 let (always, max) = unsafe { (objc2_image_io::kCGImageSourceCreateThumbnailFromImageAlways, objc2_image_io::kCGImageSourceThumbnailMaxPixelSize) };
271 let opts = dict(vec![(&always.to_string(), obj(NSNumber::new_bool(true))), (&max.to_string(), obj(NSNumber::new_u32(max_side)))]);
272 let primary = unsafe { src.primary_image_index() };
274 let image = unsafe { src.thumbnail_at_index(primary, Some(&*Retained::as_ptr(&opts).cast::<CFDictionary>())) }.ok_or("ImageIO decoded no picture")?;
277 draw(&image)
278 }
279
280 pub fn readable_types() -> Vec<String> {
281 let types: CFRetained<CFArray> = unsafe { CGImageSource::type_identifiers() };
283 let types: &NSArray<NSString> = unsafe { &*(CFRetained::as_ptr(&types).as_ptr() as *const NSArray<NSString>) };
285 types.iter().map(|s| s.to_string()).collect()
286 }
287
288 fn obj<T: objc2::Message>(r: Retained<T>) -> Retained<AnyObject> {
290 unsafe { Retained::cast_unchecked(r) }
292 }
293
294 fn dict(pairs: Vec<(&str, Retained<AnyObject>)>) -> Retained<NSDictionary<NSString, AnyObject>> {
295 let keys: Vec<Retained<NSString>> = pairs.iter().map(|(k, _)| NSString::from_str(k)).collect();
296 let keys: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
297 let values: Vec<&AnyObject> = pairs.iter().map(|(_, v)| &**v).collect();
298 NSDictionary::from_slices(&keys, &values)
299 }
300
301 fn cg_image(img: &RgbaImage) -> Result<CFRetained<CGImage>, String> {
303 let (w, h) = (img.width() as usize, img.height() as usize);
304 let mut px = img.as_raw().clone();
305 for p in px.as_chunks_mut::<4>().0 {
307 let a = u32::from(p[3]);
308 for c in &mut p[..3] {
309 *c = ((u32::from(*c) * a + 127) / 255) as u8;
310 }
311 }
312 let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })).ok_or("no sRGB colour space")?;
314 let info = CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0;
315 let ctx = unsafe { CGBitmapContextCreate(px.as_mut_ptr().cast(), w, h, 8, w * 4, Some(&space), info) }.ok_or("no bitmap context")?;
318 CGBitmapContextCreateImage(Some(&ctx)).ok_or_else(|| "no image".to_string())
319 }
320
321 pub fn write(path: &Path, uti: &str, frames: &[super::WriteFrame]) -> Result<(), String> {
322 let url = CFURL::from_file_path(path).ok_or("not a file path")?;
323 let dest = unsafe { CGImageDestination::with_url(&url, &CFString::from_str(uti), frames.len(), None) }
325 .ok_or_else(|| format!("this system can't write {uti}"))?;
326 for f in frames {
327 let mut props = vec![("Orientation", obj(NSNumber::new_u8(f.orientation)))];
328 if let Some(d) = f.delay {
329 props.push(("{HEICS}", obj(dict(vec![("DelayTime", obj(NSNumber::new_f64(d)))]))));
330 }
331 let props = dict(props);
332 let image = cg_image(f.image)?;
333 unsafe { dest.add_image(&image, Some(&*Retained::as_ptr(&props).cast::<CFDictionary>())) };
336 }
337 if unsafe { dest.finalize() } { Ok(()) } else { Err(format!("writing {uti} failed")) }
339 }
340
341 pub struct Source {
342 src: CFRetained<CGImageSource>,
343 }
344
345 impl Source {
346 pub fn open(path: &Path) -> Result<Option<(Source, Vec<u32>)>, String> {
347 let src = open_source(path)?;
348 let count = unsafe { src.count() };
350 let first = properties(&src, 0).and_then(|p| frame_delay(as_ns(&p)));
351 if !is_animated(count, first.is_some()) {
352 return Ok(None);
353 }
354 let delays = (0..count).map(|i| properties(&src, i).and_then(|p| frame_delay(as_ns(&p))).unwrap_or(0)).collect();
355 Ok(Some((Source { src }, delays)))
356 }
357
358 pub fn size(&self, i: usize) -> (u32, u32) {
359 let Some(p) = properties(&self.src, i) else { return (0, 0) };
360 let p = as_ns(&p);
361 let (w, h) = (number(p, "PixelWidth").unwrap_or(0.0) as u32, number(p, "PixelHeight").unwrap_or(0.0) as u32);
362 super::oriented_size(w, h, orientation_at(&self.src, i))
363 }
364
365 pub fn frame(&mut self, i: usize) -> Result<RgbaImage, String> {
366 decode_at(&self.src, i)
367 }
368 }
369}
370
371#[cfg(not(target_os = "macos"))]
372mod imp {
373 use super::{DynamicImage, Path, RgbaImage, ThumbError};
374
375 pub fn decode(_: &Path) -> Result<DynamicImage, ThumbError> {
376 Err(ThumbError("ImageIO is only available on macOS".into()))
377 }
378
379 pub fn readable_types() -> Vec<String> {
380 Vec::new()
381 }
382
383 pub fn decode_bytes_within(_: &[u8], _: u32) -> Result<RgbaImage, String> {
384 Err("ImageIO is only available on macOS".into())
385 }
386
387 pub fn write(_: &Path, _: &str, _: &[super::WriteFrame]) -> Result<(), String> {
388 Err("ImageIO is only available on macOS".into())
389 }
390
391 pub enum Source {}
392
393 impl Source {
394 pub fn open(_: &Path) -> Result<Option<(Source, Vec<u32>)>, String> {
395 Ok(None)
396 }
397
398 pub fn size(&self, _: usize) -> (u32, u32) {
399 match *self {}
400 }
401
402 pub fn frame(&mut self, _: usize) -> Result<RgbaImage, String> {
403 match *self {}
404 }
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn extensions_map_to_imageio_types() {
414 assert_eq!(type_of("avif"), Some("public.avif"));
415 assert_eq!(type_of("hif"), Some("public.heic"));
416 assert_eq!(type_of("jxl"), Some("public.jpeg-xl"));
417 assert!(handles("HEIC") && handles("heif") && handles("avifs") && handles("heics"));
418 assert!(!handles("jpg") && !handles("png") && !handles(""));
419 }
420
421 #[test]
422 fn exif_orientations_map_and_turn_sizes() {
423 assert_eq!(orientation(1), Orientation::NoTransforms);
424 assert_eq!(orientation(3), Orientation::Rotate180);
425 assert_eq!(orientation(6), Orientation::Rotate90);
426 assert_eq!(orientation(8), Orientation::Rotate270);
427 assert_eq!(orientation(2), Orientation::FlipHorizontal);
428 assert_eq!(orientation(0), Orientation::NoTransforms);
430 assert_eq!(orientation(9), Orientation::NoTransforms);
431 assert_eq!(orientation(-1), Orientation::NoTransforms);
432 assert_eq!(orientation(300), Orientation::NoTransforms);
433 assert_eq!(oriented_size(40, 20, orientation(6)), (20, 40));
434 assert_eq!(oriented_size(40, 20, orientation(5)), (20, 40));
435 assert_eq!(oriented_size(40, 20, orientation(3)), (40, 20));
436 }
437
438 #[test]
439 fn delays_prefer_the_unclamped_value() {
440 assert_eq!(delay_ms(Some(0.02), Some(0.1)), 20);
441 assert_eq!(delay_ms(None, Some(0.25)), 250);
442 assert_eq!(delay_ms(Some(0.0), Some(0.1)), 100);
443 assert_eq!(delay_ms(Some(f64::NAN), None), 0);
444 assert_eq!(delay_ms(None, None), 0);
445 assert_eq!(delay_ms(None, Some(-1.0)), 0);
446 assert_eq!(delay_ms(Some(1.0 / 30.0), None), 33);
447 }
448
449 #[test]
450 fn only_timed_sequences_animate() {
451 assert!(is_animated(3, true));
452 assert!(!is_animated(3, false), "an image collection is a still");
453 assert!(!is_animated(1, true));
454 }
455
456 #[test]
457 fn unpremultiplies_partial_alpha() {
458 let mut px = [64, 32, 0, 128, 10, 20, 30, 255, 0, 0, 0, 0];
459 unpremultiply(&mut px);
460 assert_eq!(px, [128, 64, 0, 128, 10, 20, 30, 255, 0, 0, 0, 0]);
461 }
462}