use anyhow::{Context, Result};
use image::{DynamicImage, RgbaImage, metadata::Orientation};
use objc2::rc::autoreleasepool;
use objc2_core_foundation::{
CFBoolean, CFData, CFDictionary, CFNumber, CFRetained, CFString, CFType, CGPoint, CGRect,
CGSize,
};
use objc2_core_graphics::{
CGBitmapContextCreate, CGColorSpace, CGContext, CGImage, CGImageAlphaInfo, kCGColorSpaceSRGB,
};
use objc2_image_io::{
CGImageSource, kCGImagePropertyOrientation, kCGImagePropertyPixelHeight,
kCGImagePropertyPixelWidth, kCGImageSourceCreateThumbnailFromImageAlways,
kCGImageSourceCreateThumbnailWithTransform, kCGImageSourceThumbnailMaxPixelSize,
};
const UNREADABLE: &str = "macOS cannot read this image";
pub fn info(bytes: &[u8]) -> Result<(u32, u32, Orientation)> {
autoreleasepool(|_| {
let properties = unsafe { source(bytes)?.properties_at_index(0, None) };
let properties: CFRetained<CFDictionary<CFString, CFType>> =
unsafe { CFRetained::cast_unchecked(properties.context(UNREADABLE)?) };
let number = |key| properties.get(key)?.downcast_ref::<CFNumber>()?.as_i64();
let size = |key| {
number(key)
.and_then(|n| n.try_into().ok())
.context(UNREADABLE)
};
let (width, height, orientation) = unsafe {
(
kCGImagePropertyPixelWidth,
kCGImagePropertyPixelHeight,
kCGImagePropertyOrientation,
)
};
let orientation = number(orientation)
.and_then(|o| Orientation::from_exif(o.try_into().ok()?))
.unwrap_or(Orientation::NoTransforms);
Ok((size(width)?, size(height)?, orientation))
})
}
pub fn load(bytes: &[u8], side: u32) -> Result<DynamicImage> {
autoreleasepool(|_| {
let side = CFNumber::new_i64(side.into());
let yes = CFBoolean::new(true);
let keys = unsafe {
[
kCGImageSourceCreateThumbnailFromImageAlways,
kCGImageSourceCreateThumbnailWithTransform,
kCGImageSourceThumbnailMaxPixelSize,
]
};
let values: [&CFType; 3] = [yes, yes, &side];
let options = CFDictionary::from_slices(&keys, &values);
let image = unsafe { source(bytes)?.thumbnail_at_index(0, Some(options.as_opaque())) }
.context(UNREADABLE)?;
draw(&image)
})
}
fn source(bytes: &[u8]) -> Result<CFRetained<CGImageSource>> {
unsafe { CGImageSource::with_data(&CFData::from_bytes(bytes), None) }.context(UNREADABLE)
}
fn draw(image: &CGImage) -> Result<DynamicImage> {
let (width, height) = (CGImage::width(Some(image)), CGImage::height(Some(image)));
let mut pixels = vec![0; width * height * 4];
let srgb = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }));
let context = unsafe {
CGBitmapContextCreate(
pixels.as_mut_ptr().cast(),
width,
height,
8,
width * 4,
srgb.as_deref(),
CGImageAlphaInfo::PremultipliedLast.0,
)
}
.context(UNREADABLE)?;
let rect = CGRect::new(CGPoint::ZERO, CGSize::new(width as f64, height as f64));
CGContext::draw_image(Some(&context), rect, Some(image));
drop(context);
let image = RgbaImage::from_raw(width.try_into()?, height.try_into()?, pixels);
Ok(image.context(UNREADABLE)?.into())
}
#[cfg(test)]
mod tests {
use image::RgbaImage;
use jiff::civil::date;
use crate::{
decode::{header, load},
meta::{self, Exif, Meta},
testing::Temp,
};
const RED: [u8; 3] = [255, 0, 0];
const BLUE: [u8; 3] = [0, 0, 255];
fn near(image: &RgbaImage, x: u32, y: u32, rgb: [u8; 3]) -> bool {
let pixel = image.get_pixel(x, y).0;
(0..3).all(|c| pixel[c].abs_diff(rgb[c]) < 40)
}
#[test]
fn reads_heic() {
let file = Temp::file("turned.heic", include_bytes!("testdata/turned.heic"));
let exif = Exif {
taken: Some(date(2024, 6, 15).at(12, 0, 0, 0)),
camera: Some("Apple iPhone 15".into()),
gps: false,
};
let meta = meta::read(&file).unwrap();
assert_eq!(
meta,
Meta {
width: 64,
height: 128,
exif
}
);
let image = load(&file, 32).unwrap().into_rgba8();
assert_eq!(image.dimensions(), (16, 32));
assert!(near(&image, 8, 4, RED) && near(&image, 8, 28, BLUE));
}
#[test]
fn reads_avif_and_jpeg_xl() {
let files: [(&str, &[u8]); 2] = [
("plain.avif", include_bytes!("testdata/plain.avif")),
("plain.jxl", include_bytes!("testdata/plain.jxl")),
];
for (name, bytes) in files {
let file = Temp::file(name, bytes);
assert_eq!(header(&file).unwrap().size(), (128, 64), "{name}");
let image = load(&file, 512).unwrap().into_rgba8();
assert_eq!(image.dimensions(), (128, 64), "{name}");
assert!(
near(&image, 16, 32, RED) && near(&image, 112, 32, BLUE),
"{name}"
);
}
}
}