use std::io::{Cursor, Read};
use std::path::Path;
use std::sync::Arc;
use image::{DynamicImage, GenericImageView, ImageFormat, RgbaImage};
use resvg::{tiny_skia, usvg};
use tracing::{debug, warn};
pub const MAX_IMAGE_DIMENSION: u32 = 2000;
pub const MAX_SOURCE_BYTES: usize = 20 * 1024 * 1024;
const JPEG_QUALITY: u8 = 85;
#[derive(Debug)]
pub struct PreparedVisionImage {
pub data: Vec<u8>,
pub mime_type: &'static str,
pub width: u32,
pub height: u32,
}
pub fn load_and_normalize(path: &Path) -> std::io::Result<PreparedVisionImage> {
let bytes = read_bounded(path)?;
normalize_bytes(&bytes)
}
fn read_bounded(path: &Path) -> std::io::Result<Vec<u8>> {
let file = std::fs::File::open(path)?;
let mut buf = Vec::with_capacity(MAX_SOURCE_BYTES.min(1 << 20));
let mut capped = file.take((MAX_SOURCE_BYTES + 1) as u64);
capped.read_to_end(&mut buf)?;
if buf.len() > MAX_SOURCE_BYTES {
warn!(
len = buf.len(),
max = MAX_SOURCE_BYTES,
"image exceeds the maximum source size",
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"image exceeds the maximum source size of {}",
humfmt::bytes(MAX_SOURCE_BYTES as u64)
),
));
}
Ok(buf)
}
pub fn normalize_bytes(bytes: &[u8]) -> std::io::Result<PreparedVisionImage> {
if is_heic(bytes) {
normalize_heic(bytes)
} else if is_svg(bytes) {
normalize_svg(bytes)
} else {
normalize_raster(bytes)
}
}
fn is_supported_raster(format: ImageFormat) -> bool {
matches!(
format,
ImageFormat::Jpeg
| ImageFormat::Png
| ImageFormat::Gif
| ImageFormat::WebP
| ImageFormat::Pnm
| ImageFormat::Tiff
| ImageFormat::Tga
| ImageFormat::Dds
| ImageFormat::Bmp
| ImageFormat::Ico
| ImageFormat::Hdr
| ImageFormat::OpenExr
| ImageFormat::Farbfeld
| ImageFormat::Qoi
) || (format == ImageFormat::Avif && cfg!(feature = "avif"))
}
fn normalize_raster(bytes: &[u8]) -> std::io::Result<PreparedVisionImage> {
let format = image::guess_format(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
if !is_supported_raster(format) {
warn!(?format, "unsupported image format");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported image format: {format:?}"),
));
}
let img = choreo_image::decode_raster_oriented(bytes).map_err(|e| {
warn!(error = %e, "failed to decode image (unsupported or decompression-bomb source)");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
finalize(img, &format!("{format:?}"))
}
fn normalize_heic(bytes: &[u8]) -> std::io::Result<PreparedVisionImage> {
let img = choreo_image::decode_heic(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
finalize(img, "heic")
}
fn normalize_svg(bytes: &[u8]) -> std::io::Result<PreparedVisionImage> {
let img = rasterize_svg(bytes)?;
finalize(img, "svg")
}
fn finalize(img: DynamicImage, source_label: &str) -> std::io::Result<PreparedVisionImage> {
let (source_width, source_height) = img.dimensions();
let resized = if source_width > MAX_IMAGE_DIMENSION || source_height > MAX_IMAGE_DIMENSION {
img.resize(
MAX_IMAGE_DIMENSION,
MAX_IMAGE_DIMENSION,
image::imageops::FilterType::Lanczos3,
)
} else {
img
};
let (data, mime_type) = if resized.color().has_alpha() {
(encode_png(&resized)?, "image/png")
} else {
(encode_jpeg(&resized)?, "image/jpeg")
};
debug!(
source = source_label,
source_width,
source_height,
mime = mime_type,
output_bytes = data.len(),
"normalized image",
);
let (width, height) = resized.dimensions();
Ok(PreparedVisionImage {
data,
mime_type,
width,
height,
})
}
fn rasterize_svg(bytes: &[u8]) -> std::io::Result<DynamicImage> {
let mut options = usvg::Options::default();
Arc::make_mut(&mut options.fontdb).load_system_fonts();
let tree = usvg::Tree::from_data(bytes, &options)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
let size = tree.size();
let intrinsic_w = size.width();
let intrinsic_h = size.height();
let longest = intrinsic_w.max(intrinsic_h);
let scale = if longest > MAX_IMAGE_DIMENSION as f32 {
MAX_IMAGE_DIMENSION as f32 / longest
} else {
1.0
};
let out_w = (intrinsic_w * scale).ceil().max(1.0) as u32;
let out_h = (intrinsic_h * scale).ceil().max(1.0) as u32;
let mut pixmap = tiny_skia::Pixmap::new(out_w, out_h).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"svg dimensions are too large to rasterize",
)
})?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let rgba = RgbaImage::from_raw(out_w, out_h, pixmap.take()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"failed to build raster image from svg",
)
})?;
Ok(DynamicImage::ImageRgba8(rgba))
}
fn is_heic(bytes: &[u8]) -> bool {
let head = match bytes.get(..12) {
Some(h) => h,
None => return false,
};
if bytes.get(4..8) != Some(b"ftyp") {
return false;
}
let size = u32::from_be_bytes(
head.get(..4)
.and_then(|s| s.try_into().ok())
.unwrap_or([0u8; 4]),
);
let box_end = match size {
0 | 1 => bytes.len(),
n => (n as usize).min(bytes.len()),
};
let mut has_heif_brand = false;
let mut off = 8;
while off + 4 <= box_end {
let brand = bytes.get(off..off + 4).unwrap_or_default();
if matches!(brand, b"avif" | b"avis") {
return false;
}
if matches!(
brand,
b"heic" | b"heix" | b"hevc" | b"hevx" | b"heif" | b"heim" | b"heis" | b"mif1" | b"msf1"
) {
has_heif_brand = true;
}
off += 4;
}
has_heif_brand
}
fn is_svg(bytes: &[u8]) -> bool {
let trimmed = bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.and_then(|i| bytes.get(i..))
.unwrap_or(bytes);
if trimmed.first() != Some(&b'<') {
return false;
}
let window = trimmed.get(..trimmed.len().min(512)).unwrap_or(trimmed);
let lower = window.to_ascii_lowercase();
lower.windows(4).any(|w| w == b"<svg")
}
fn encode_png(img: &DynamicImage) -> std::io::Result<Vec<u8>> {
let mut out = Cursor::new(Vec::new());
img.write_to(&mut out, ImageFormat::Png).map_err(io_err)?;
Ok(out.into_inner())
}
fn encode_jpeg(img: &DynamicImage) -> std::io::Result<Vec<u8>> {
use image::ExtendedColorType;
use image::codecs::jpeg::JpegEncoder;
let rgb = img.to_rgb8();
let mut out = Cursor::new(Vec::new());
let mut encoder = JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY);
encoder
.encode(&rgb, rgb.width(), rgb.height(), ExtendedColorType::Rgb8)
.map_err(io_err)?;
Ok(out.into_inner())
}
fn io_err(e: image::ImageError) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb, Rgba};
fn opaque_rgb() -> DynamicImage {
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_fn(3, 2, |x, y| Rgb([(x * 80) as u8, (y * 90) as u8, 40]));
DynamicImage::ImageRgb8(buf)
}
fn as_png(img: &DynamicImage) -> Vec<u8> {
let mut out = Cursor::new(Vec::new());
img.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn opaque_image_reencodes_to_jpeg() {
let bytes = as_png(&opaque_rgb());
let out = normalize_bytes(&bytes).unwrap();
assert_eq!(out.mime_type, "image/jpeg");
assert_eq!(out.width, 3);
assert_eq!(out.height, 2);
let decoded = image::load_from_memory(&out.data).unwrap();
assert_eq!(decoded.dimensions(), (3, 2));
}
#[test]
fn transparent_image_reencodes_to_png() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::from_fn(4, 4, |x, y| {
Rgba([x as u8, y as u8, 0, if x % 2 == 0 { 0 } else { 255 }])
});
let img = DynamicImage::ImageRgba8(buf);
let bytes = as_png(&img);
let out = normalize_bytes(&bytes).unwrap();
assert_eq!(out.mime_type, "image/png");
assert_eq!(out.width, 4);
assert_eq!(out.height, 4);
}
#[test]
fn oversized_image_is_downscaled() {
let buf: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_fn(4000, 2000, |x, y| Rgba([x as u8, y as u8, 100, 255]));
let img = DynamicImage::ImageRgba8(buf);
let bytes = as_png(&img);
let out = normalize_bytes(&bytes).unwrap();
assert!(out.width <= MAX_IMAGE_DIMENSION);
assert!(out.height <= MAX_IMAGE_DIMENSION);
assert_eq!(out.width, 2000);
assert_eq!(out.height, 1000);
}
#[cfg(not(feature = "avif"))]
#[test]
fn gated_avif_is_rejected_when_feature_disabled() {
let err = normalize_bytes(b"\0\0\0\x18ftypavif").unwrap_err();
assert!(
err.to_string().contains("unsupported image format"),
"{err}"
);
}
#[test]
fn bmp_is_now_supported() {
let err = normalize_bytes(b"BM\0\0\0\0\0\0\0\0").unwrap_err();
assert!(
!err.to_string().contains("unsupported image format"),
"{err}"
);
}
#[test]
fn empty_bytes_are_rejected() {
assert!(normalize_bytes(&[]).is_err());
}
#[test]
fn heic_is_detected_by_ftyp_brand() {
assert!(is_heic(b"\0\0\0\x18ftypheic\x00\x00\x00\x00heicmif1"));
assert!(is_heic(b"\0\0\0\x18ftypheix\x00\x00\x00\x00mif1heix"));
assert!(!is_heic(b"\0\0\0\x18ftypavif\x00\x00\x00\x00avifmif1"));
assert!(!is_heic(b"not a box at all"));
}
#[test]
fn svg_is_detected_by_content() {
assert!(is_svg(b"<svg xmlns='http://www.w3.org/2000/svg'></svg>"));
assert!(is_svg(b" \n<?xml version='1.0'?><svg></svg>"));
assert!(!is_svg(b"\x89PNG\r\n\x1a\n"));
assert!(!is_svg(b"plain text"));
}
}