use crate::{checked_pixels, DecodeLimits, Metadata};
use anyhow::{bail, Context, Result};
use image::{DynamicImage, ImageDecoder, ImageReader};
use std::io::Cursor;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmbeddableFormat {
Png,
Jpeg,
WebP,
Heif,
}
#[derive(Clone, Debug)]
pub struct Source {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
pub metadata: Metadata,
pub embeddable: Option<EmbeddableFormat>,
pub notes: Vec<String>,
}
pub fn embeddable_format(bytes: &[u8]) -> Option<EmbeddableFormat> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(EmbeddableFormat::Png)
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(EmbeddableFormat::Jpeg)
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some(EmbeddableFormat::WebP)
} else if is_heif(bytes) {
Some(EmbeddableFormat::Heif)
} else {
None
}
}
fn is_heif(bytes: &[u8]) -> bool {
const BRANDS: [&[u8; 4]; 9] = [
b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"mif1", b"msf1", b"heif",
];
bytes.len() >= 12 && &bytes[4..8] == b"ftyp" && BRANDS.iter().any(|b| &bytes[8..12] == *b)
}
pub fn read_source(bytes: &[u8], limits: DecodeLimits) -> Result<Source> {
let embeddable = embeddable_format(bytes);
let mut source = if embeddable == Some(EmbeddableFormat::Heif) {
read_heif(bytes, limits)?
} else {
read_with_image(bytes, limits)?
};
source.embeddable = embeddable;
Ok(source)
}
fn read_with_image(bytes: &[u8], limits: DecodeLimits) -> Result<Source> {
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
let mut image_limits = image::Limits::default();
image_limits.max_alloc = Some(limits.max_alloc as u64);
reader.limits(image_limits);
let mut decoder = reader
.into_decoder()
.context("unsupported or corrupt image")?;
let (w, h) = decoder.dimensions();
checked_pixels(w, h, limits.max_pixels)?;
let icc_profile = decoder.icc_profile().ok().flatten();
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let mut image = DynamicImage::from_decoder(decoder)?;
image.apply_orientation(orientation);
let mut notes = Vec::new();
let color = image.color();
if color.bytes_per_pixel() > color.channel_count() {
notes.push("samples above 8 bits were reduced to 8 bits".into());
}
let rgba = image.to_rgba8();
Ok(Source {
width: rgba.width(),
height: rgba.height(),
rgba: rgba.into_raw(),
metadata: Metadata { icc_profile },
embeddable: None,
notes,
})
}
fn read_heif(bytes: &[u8], limits: DecodeLimits) -> Result<Source> {
let options = heic_rs::DecodeOptions::default()
.with_layout(heic_rs::PixelLayout::Rgba8)
.with_alpha(true)
.with_max_pixels(Some(limits.max_pixels as u64));
let decoded = heic_rs::decode(bytes, &options).context("decoding HEIC/HEIF")?;
checked_pixels(decoded.width, decoded.height, limits.max_pixels)?;
if decoded.data.len() != decoded.width as usize * decoded.height as usize * 4 {
bail!("invalid HEIC RGBA dimensions");
}
let icc_profile = heic_rs::context::Context::open(bytes)
.ok()
.and_then(|ctx| ctx.props(ctx.meta.primary).ok())
.and_then(|props| props.icc.map(<[u8]>::to_vec));
let mut notes = Vec::new();
if heic_rs::probe(bytes).is_ok_and(|info| info.bit_depth > 8) {
notes.push("samples above 8 bits were reduced to 8 bits".into());
}
Ok(Source {
width: decoded.width,
height: decoded.height,
rgba: decoded.data,
metadata: Metadata { icc_profile },
embeddable: None,
notes,
})
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use image::RgbaImage;
pub(crate) fn jpeg_with_orientation(img: &RgbaImage, value: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
DynamicImage::ImageRgba8(img.clone())
.to_rgb8()
.write_to(&mut Cursor::new(&mut jpeg), image::ImageFormat::Jpeg)
.unwrap();
let mut tiff = b"II*\0".to_vec();
tiff.extend(8u32.to_le_bytes());
tiff.extend(1u16.to_le_bytes());
tiff.extend(0x0112u16.to_le_bytes());
tiff.extend(3u16.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(value.to_le_bytes());
tiff.extend([0, 0]);
tiff.extend(0u32.to_le_bytes());
let mut app1 = b"Exif\0\0".to_vec();
app1.extend(tiff);
let mut out = jpeg[..2].to_vec();
out.extend([0xFF, 0xE1]);
out.extend(((app1.len() + 2) as u16).to_be_bytes());
out.extend(app1);
out.extend(&jpeg[2..]);
out
}
#[test]
fn applies_exif_orientation() {
let img = RgbaImage::from_fn(16, 8, |x, _| {
let v = if x < 8 { 0 } else { 255 };
image::Rgba([v, v, v, 255])
});
let source = read_source(&jpeg_with_orientation(&img, 6), DecodeLimits::default()).unwrap();
let loaded = RgbaImage::from_raw(source.width, source.height, source.rgba).unwrap();
assert_eq!(loaded.dimensions(), (8, 16));
assert!(loaded.get_pixel(4, 2)[0] < 40);
assert!(loaded.get_pixel(4, 13)[0] > 215);
assert_eq!(source.embeddable, Some(EmbeddableFormat::Jpeg));
}
#[test]
fn identifies_embeddable_formats_by_signature() {
let png = {
let mut out = Vec::new();
RgbaImage::new(2, 2)
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
};
assert_eq!(embeddable_format(&png), Some(EmbeddableFormat::Png));
assert_eq!(
embeddable_format(b"RIFF\0\0\0\0WEBPVP8L"),
Some(EmbeddableFormat::WebP)
);
assert_eq!(
embeddable_format(b"\0\0\0\x18ftypheic\0\0\0\0"),
Some(EmbeddableFormat::Heif)
);
assert_eq!(embeddable_format(b"BM\0\0\0\0\0\0"), None);
assert_eq!(embeddable_format(b"BIC2"), None);
}
#[test]
fn refuses_images_over_the_pixel_limit_before_decoding() {
let img = RgbaImage::new(64, 64);
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let limits = DecodeLimits {
max_pixels: 1000,
..Default::default()
};
assert!(read_source(&png, limits).is_err());
}
}