use crate::core::{
Artifact, ArtifactMetadata, CropRegion, Fit, MAX_DECODED_PIXELS, MAX_OUTPUT_PIXELS, MediaType,
MetadataKind, MetadataPolicy, NormalizedTransformOptions, NormalizedTransformRequest,
OptimizeMode, Position, QualityMetric, Rotation, TargetQuality, TransformError,
TransformRequest, TransformResult, TransformWarning, WatermarkInput,
default_lossy_target_quality,
};
use crate::{RawArtifact, Rgba8, sniff_artifact};
use exif::{In, Reader, Tag, Value};
#[cfg(feature = "avif")]
use image::codecs::avif::AvifEncoder;
use image::codecs::jpeg::JpegDecoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::{
CompressionType as PngCompressionType, FilterType as PngFilterType, PngDecoder, PngEncoder,
};
use image::codecs::webp::WebPDecoder;
use image::codecs::webp::WebPEncoder;
use image::imageops::{self, FilterType};
use image::metadata::Orientation;
use image::{
ColorType, DynamicImage, GenericImageView, ImageDecoder, ImageEncoder, ImageFormat, Rgba,
RgbaImage,
};
#[cfg(feature = "avif")]
use mp4parse::ParseStrictness;
#[cfg(feature = "avif")]
use rav1d_safe::{Decoder, Planes};
use std::io::Cursor;
use std::time::{Duration, Instant};
#[cfg(feature = "avif")]
use yuvutils_rs::{YuvGrayImage, YuvPlanarImage, YuvRange, YuvStandardMatrix};
macro_rules! check_deadline_if_set {
($start:expr, $deadline:expr, $stage:expr) => {
if let (Some(start), Some(limit)) = ($start, $deadline) {
check_deadline(start.elapsed(), limit, $stage)?;
}
};
}
#[must_use = "this function returns the transform result without side effects"]
pub fn transform_raster(request: TransformRequest) -> Result<TransformResult, TransformError> {
let normalized = request.normalize()?;
if let Some(result) = try_passthrough_lossless_optimization(&normalized)? {
return Ok(result);
}
let deadline = normalized.options.deadline;
let start = deadline.map(|_| Instant::now());
let (retained_metadata, mut warnings) = extract_retained_metadata(
&normalized.input,
normalized.options.metadata_policy,
normalized.options.auto_orient,
normalized.options.format,
)?;
check_input_pixel_limit(&normalized.input)?;
let mut image = decode_input(&normalized.input)?;
check_deadline_if_set!(start, deadline, "decode");
if normalized.options.auto_orient {
image = apply_auto_orientation(image, &normalized.input);
}
image = apply_rotation(image, normalized.options.rotate);
check_deadline_if_set!(start, deadline, "rotate");
if let Some(crop) = normalized.options.crop {
image = apply_crop(image, crop)?;
check_deadline_if_set!(start, deadline, "crop");
}
check_output_pixel_limit(&image, normalized.options.width, normalized.options.height)?;
image = apply_resize(
image,
normalized.options.width,
normalized.options.height,
normalized.options.fit,
normalized.options.position,
normalized.options.background,
normalized.options.format,
);
check_deadline_if_set!(start, deadline, "resize");
if let Some(sigma) = normalized.options.blur {
image = image.blur(sigma);
check_deadline_if_set!(start, deadline, "blur");
}
if let Some(sigma) = normalized.options.sharpen {
image = image.unsharpen(sigma, 1);
check_deadline_if_set!(start, deadline, "sharpen");
}
if let Some(ref wm) = normalized.watermark {
image = apply_watermark(image, wm)?;
check_deadline_if_set!(start, deadline, "watermark");
}
let encoded = encode_output(
&image,
normalized.options.format,
&normalized.options,
retained_metadata.as_ref(),
EncodeDeadline { start, deadline },
)?;
check_deadline_if_set!(start, deadline, "encode");
if normalized.options.format == MediaType::Webp
&& encoded.used_lossy_webp
&& let Some(metadata) = retained_metadata.as_ref()
{
if metadata.exif_metadata.is_some() {
warnings.push(TransformWarning::MetadataDropped(MetadataKind::Exif));
}
if metadata.icc_profile.is_some() {
warnings.push(TransformWarning::MetadataDropped(MetadataKind::Icc));
}
}
let bytes = if let Some(ref metadata) = retained_metadata {
inject_metadata(
encoded.bytes,
normalized.options.format,
metadata,
&mut warnings,
)
} else {
encoded.bytes
};
let (width, height) = image.dimensions();
Ok(TransformResult {
artifact: Artifact::new(
bytes,
normalized.options.format,
ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(output_has_alpha(&image, normalized.options.format)),
},
),
warnings,
})
}
fn decode_input(input: &Artifact) -> Result<DynamicImage, TransformError> {
let image_format = match input.media_type {
MediaType::Jpeg => ImageFormat::Jpeg,
MediaType::Png => ImageFormat::Png,
MediaType::Webp => ImageFormat::WebP,
MediaType::Avif => {
#[cfg(feature = "avif")]
{
return decode_avif(&input.bytes);
}
#[cfg(not(feature = "avif"))]
{
return Err(TransformError::CapabilityMissing(
"AVIF decoding is not enabled in this build".to_string(),
));
}
}
MediaType::Bmp => ImageFormat::Bmp,
MediaType::Tiff => ImageFormat::Tiff,
MediaType::Svg => {
return Err(TransformError::UnsupportedInputMediaType(
"SVG input should be routed to transform_svg, not transform_raster".into(),
));
}
};
image::load_from_memory_with_format(&input.bytes, image_format)
.map_err(|error| TransformError::DecodeFailed(error.to_string()))
}
#[cfg(feature = "avif")]
fn decode_avif(bytes: &[u8]) -> Result<DynamicImage, TransformError> {
let mut cursor = Cursor::new(bytes);
let context = mp4parse::read_avif(&mut cursor, ParseStrictness::Normal)
.map_err(|e| TransformError::DecodeFailed(format!("AVIF container parse failed: {e}")))?;
let primary_data = context
.primary_item_coded_data()
.ok_or_else(|| TransformError::DecodeFailed("AVIF has no primary item data".into()))?;
let frame = decode_av1_frame(primary_data)?;
let width = frame.width();
let height = frame.height();
let color = frame.color_info();
let matrix = map_yuv_matrix(color.matrix_coefficients);
let range = map_yuv_range(color.color_range);
let mut rgba = yuv_frame_to_rgba(&frame, width, height, range, matrix)?;
if let Some(alpha_data) = context.alpha_item_coded_data() {
let alpha_frame = decode_av1_frame(alpha_data)
.map_err(|e| TransformError::DecodeFailed(format!("AVIF alpha decode failed: {e}")))?;
merge_alpha_plane(&alpha_frame, &mut rgba, width, height);
}
RgbaImage::from_raw(width, height, rgba)
.map(DynamicImage::ImageRgba8)
.ok_or_else(|| TransformError::DecodeFailed("AVIF decoded buffer size mismatch".into()))
}
#[cfg(feature = "avif")]
fn decode_av1_frame(obu_data: &[u8]) -> Result<rav1d_safe::Frame, TransformError> {
let mut decoder = Decoder::new()
.map_err(|e| TransformError::DecodeFailed(format!("AV1 decoder init failed: {e}")))?;
if let Some(frame) = decoder
.decode(obu_data)
.map_err(|e| TransformError::DecodeFailed(format!("AV1 decode failed: {e}")))?
{
return Ok(frame);
}
let frames = decoder
.flush()
.map_err(|e| TransformError::DecodeFailed(format!("AV1 flush failed: {e}")))?;
frames
.into_iter()
.next()
.ok_or_else(|| TransformError::DecodeFailed("AV1 decoder produced no frames".into()))
}
#[cfg(feature = "avif")]
fn map_yuv_matrix(mc: rav1d_safe::MatrixCoefficients) -> YuvStandardMatrix {
match mc {
rav1d_safe::MatrixCoefficients::BT601 => YuvStandardMatrix::Bt601,
rav1d_safe::MatrixCoefficients::BT470BG => YuvStandardMatrix::Bt601,
rav1d_safe::MatrixCoefficients::BT2020NCL => YuvStandardMatrix::Bt2020,
rav1d_safe::MatrixCoefficients::BT2020CL => YuvStandardMatrix::Bt2020,
rav1d_safe::MatrixCoefficients::SMPTE240 => YuvStandardMatrix::Smpte240,
_ => YuvStandardMatrix::Bt709,
}
}
#[cfg(feature = "avif")]
fn map_yuv_range(cr: rav1d_safe::ColorRange) -> YuvRange {
match cr {
rav1d_safe::ColorRange::Full => YuvRange::Full,
rav1d_safe::ColorRange::Limited => YuvRange::Limited,
}
}
#[cfg(feature = "avif")]
fn yuv_frame_to_rgba(
frame: &rav1d_safe::Frame,
width: u32,
height: u32,
range: YuvRange,
matrix: YuvStandardMatrix,
) -> Result<Vec<u8>, TransformError> {
let rgba_stride = width.checked_mul(4).ok_or_else(|| {
TransformError::DecodeFailed("AVIF frame dimensions overflow address space".into())
})?;
let total_bytes = (width as usize)
.checked_mul(height as usize)
.and_then(|n| n.checked_mul(4))
.ok_or_else(|| {
TransformError::DecodeFailed("AVIF frame dimensions overflow address space".into())
})?;
let mut rgba = vec![255u8; total_bytes];
let layout = frame.pixel_layout();
match frame.planes() {
Planes::Depth8(planes) => {
let y = planes.y();
convert_8bit_yuv_to_rgba(
layout,
y.as_slice(),
y.stride(),
planes.u().as_ref().map(|p| (p.as_slice(), p.stride())),
planes.v().as_ref().map(|p| (p.as_slice(), p.stride())),
width,
height,
&mut rgba,
rgba_stride,
range,
matrix,
)?;
}
Planes::Depth16(planes) => {
let bit_depth = frame.bit_depth();
let shift = bit_depth - 8;
let round = 1u16 << (shift - 1);
let y8: Vec<u8> = planes
.y()
.as_slice()
.iter()
.map(|&v| ((v.saturating_add(round)) >> shift) as u8)
.collect();
let y_stride = planes.y().stride();
let u8s: Option<(Vec<u8>, usize)> = planes.u().as_ref().map(|p| {
let data: Vec<u8> = p
.as_slice()
.iter()
.map(|&v| ((v.saturating_add(round)) >> shift) as u8)
.collect();
(data, p.stride())
});
let v8s: Option<(Vec<u8>, usize)> = planes.v().as_ref().map(|p| {
let data: Vec<u8> = p
.as_slice()
.iter()
.map(|&v| ((v.saturating_add(round)) >> shift) as u8)
.collect();
(data, p.stride())
});
convert_8bit_yuv_to_rgba(
layout,
&y8,
y_stride,
u8s.as_ref().map(|(d, s)| (d.as_slice(), *s)),
v8s.as_ref().map(|(d, s)| (d.as_slice(), *s)),
width,
height,
&mut rgba,
rgba_stride,
range,
matrix,
)?;
}
}
Ok(rgba)
}
#[cfg(feature = "avif")]
#[allow(clippy::too_many_arguments)]
fn convert_8bit_yuv_to_rgba(
layout: rav1d_safe::PixelLayout,
y_data: &[u8],
y_stride: usize,
u_data: Option<(&[u8], usize)>,
v_data: Option<(&[u8], usize)>,
width: u32,
height: u32,
rgba: &mut [u8],
rgba_stride: u32,
range: YuvRange,
matrix: YuvStandardMatrix,
) -> Result<(), TransformError> {
match layout {
rav1d_safe::PixelLayout::I400 => {
let gray = YuvGrayImage {
y_plane: y_data,
y_stride: y_stride as u32,
width,
height,
};
yuvutils_rs::yuv400_to_rgba(&gray, rgba, rgba_stride, range, matrix)
.map_err(|e| TransformError::DecodeFailed(format!("YUV400→RGBA failed: {e}")))?;
}
_ => {
let (u_plane, u_stride) = u_data.ok_or_else(|| {
TransformError::DecodeFailed("missing U plane for non-grayscale AVIF".into())
})?;
let (v_plane, v_stride) = v_data.ok_or_else(|| {
TransformError::DecodeFailed("missing V plane for non-grayscale AVIF".into())
})?;
let planar = YuvPlanarImage {
y_plane: y_data,
y_stride: y_stride as u32,
u_plane,
u_stride: u_stride as u32,
v_plane,
v_stride: v_stride as u32,
width,
height,
};
let convert_fn = match layout {
rav1d_safe::PixelLayout::I420 => yuvutils_rs::yuv420_to_rgba,
rav1d_safe::PixelLayout::I422 => yuvutils_rs::yuv422_to_rgba,
rav1d_safe::PixelLayout::I444 => yuvutils_rs::yuv444_to_rgba,
rav1d_safe::PixelLayout::I400 => unreachable!(),
};
convert_fn(&planar, rgba, rgba_stride, range, matrix)
.map_err(|e| TransformError::DecodeFailed(format!("YUV→RGBA failed: {e}")))?;
}
}
Ok(())
}
#[cfg(feature = "avif")]
fn merge_alpha_plane(alpha_frame: &rav1d_safe::Frame, rgba: &mut [u8], width: u32, height: u32) {
if alpha_frame.width() != width || alpha_frame.height() != height {
return;
}
let w = width as usize;
let row_stride = w.saturating_mul(4);
match alpha_frame.planes() {
Planes::Depth8(planes) => {
let y = planes.y();
for row_idx in 0..height as usize {
let row = y.row(row_idx);
let row_start = row_idx.saturating_mul(row_stride);
for (col, &alpha) in row.iter().enumerate().take(w) {
let idx = row_start + col * 4 + 3;
if idx < rgba.len() {
rgba[idx] = alpha;
}
}
}
}
Planes::Depth16(planes) => {
let shift = alpha_frame.bit_depth() - 8;
let y = planes.y();
for row_idx in 0..height as usize {
let row = y.row(row_idx);
let row_start = row_idx.saturating_mul(row_stride);
for (col, &alpha) in row.iter().enumerate().take(w) {
let idx = row_start + col * 4 + 3;
if idx < rgba.len() {
rgba[idx] = (alpha >> shift) as u8;
}
}
}
}
}
}
pub(crate) fn check_deadline(
elapsed: Duration,
limit: Duration,
stage: &str,
) -> Result<(), TransformError> {
if elapsed > limit {
return Err(TransformError::LimitExceeded(format!(
"transform exceeded {:.0}s deadline after {stage} (elapsed: {:.1}s)",
limit.as_secs_f64(),
elapsed.as_secs_f64()
)));
}
Ok(())
}
fn check_input_pixel_limit(input: &Artifact) -> Result<(), TransformError> {
if let (Some(w), Some(h)) = (input.metadata.width, input.metadata.height) {
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_DECODED_PIXELS {
return Err(TransformError::LimitExceeded(format!(
"decoded image has {pixels} pixels, limit is {MAX_DECODED_PIXELS}"
)));
}
}
Ok(())
}
fn check_output_pixel_limit(
image: &DynamicImage,
width: Option<u32>,
height: Option<u32>,
) -> Result<(), TransformError> {
let (current_w, current_h) = image.dimensions();
let out_w = width.unwrap_or(current_w);
let out_h = height.unwrap_or(current_h);
let pixels = u64::from(out_w) * u64::from(out_h);
if pixels > MAX_OUTPUT_PIXELS {
return Err(TransformError::LimitExceeded(format!(
"output image would have {pixels} pixels, limit is {MAX_OUTPUT_PIXELS}"
)));
}
Ok(())
}
fn apply_auto_orientation(image: DynamicImage, input: &Artifact) -> DynamicImage {
if input.media_type != MediaType::Jpeg {
return image;
}
let mut cursor = Cursor::new(&input.bytes);
let Ok(exif) = Reader::new().read_from_container(&mut cursor) else {
return image;
};
let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) else {
return image;
};
let Some(orientation) = first_orientation_value(&field.value) else {
return image;
};
apply_exif_orientation(image, orientation)
}
fn first_orientation_value(value: &Value) -> Option<u32> {
match value {
Value::Short(values) => values.first().map(|value| u32::from(*value)),
Value::Long(values) => values.first().copied(),
_ => None,
}
}
fn apply_exif_orientation(image: DynamicImage, orientation: u32) -> DynamicImage {
match orientation {
2 => image.fliph(),
3 => image.rotate180(),
4 => image.flipv(),
5 => image.fliph().rotate90(),
6 => image.rotate90(),
7 => image.fliph().rotate270(),
8 => image.rotate270(),
_ => image,
}
}
fn apply_rotation(image: DynamicImage, rotation: Rotation) -> DynamicImage {
match rotation {
Rotation::Deg0 => image,
Rotation::Deg90 => image.rotate90(),
Rotation::Deg180 => image.rotate180(),
Rotation::Deg270 => image.rotate270(),
}
}
fn apply_crop(image: DynamicImage, crop: CropRegion) -> Result<DynamicImage, TransformError> {
let (iw, ih) = image.dimensions();
if crop.x.saturating_add(crop.width) > iw || crop.y.saturating_add(crop.height) > ih {
return Err(TransformError::InvalidOptions(format!(
"crop region {}x{}+{}+{} exceeds image bounds {}x{}",
crop.width, crop.height, crop.x, crop.y, iw, ih
)));
}
Ok(image.crop_imm(crop.x, crop.y, crop.width, crop.height))
}
fn apply_resize(
image: DynamicImage,
width: Option<u32>,
height: Option<u32>,
fit: Option<Fit>,
position: Position,
background: Option<Rgba8>,
output_format: MediaType,
) -> DynamicImage {
let (original_width, original_height) = image.dimensions();
match (width, height) {
(None, None) => image,
(Some(target_width), None) => {
let target_height = scale_dimension(original_height, target_width, original_width);
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
(None, Some(target_height)) => {
let target_width = scale_dimension(original_width, target_height, original_height);
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
(Some(target_width), Some(target_height)) => match fit.unwrap_or(Fit::Contain) {
Fit::Fill => image.resize_exact(target_width, target_height, FilterType::Lanczos3),
Fit::Contain => {
let resized = image.resize(target_width, target_height, FilterType::Lanczos3);
pad_to_box(
resized,
target_width,
target_height,
position,
background,
output_format,
)
}
Fit::Inside => {
let resized = if original_width <= target_width && original_height <= target_height
{
image
} else {
image.resize(target_width, target_height, FilterType::Lanczos3)
};
pad_to_box(
resized,
target_width,
target_height,
position,
background,
output_format,
)
}
Fit::Cover => cover_to_box(
image,
target_width,
target_height,
position,
background,
output_format,
),
},
}
}
fn apply_watermark(
image: DynamicImage,
watermark: &WatermarkInput,
) -> Result<DynamicImage, TransformError> {
let (main_w, main_h) = image.dimensions();
if let (Some(meta_w), Some(meta_h)) = (
watermark.image.metadata.width,
watermark.image.metadata.height,
) {
let margin = watermark.margin;
let (mx, my) = match watermark.position {
Position::Center => (0, 0),
Position::Top | Position::Bottom => (0, margin),
Position::Left | Position::Right => (margin, 0),
_ => (margin, margin),
};
if u64::from(meta_w) + u64::from(mx) > u64::from(main_w)
|| u64::from(meta_h) + u64::from(my) > u64::from(main_h)
{
return Err(TransformError::InvalidOptions(
"watermark image is too large for the output dimensions".to_string(),
));
}
}
if let (Some(w), Some(h)) = (
watermark.image.metadata.width,
watermark.image.metadata.height,
) {
let pixels = u64::from(w) * u64::from(h);
if pixels > crate::MAX_WATERMARK_PIXELS {
return Err(TransformError::LimitExceeded(format!(
"watermark image has {pixels} pixels, limit is {}",
crate::MAX_WATERMARK_PIXELS
)));
}
}
check_input_pixel_limit(&watermark.image)?;
let wm_image = decode_input(&watermark.image)?;
let (decoded_w, decoded_h) = wm_image.dimensions();
if let (Some(meta_w), Some(meta_h)) = (
watermark.image.metadata.width,
watermark.image.metadata.height,
) && (decoded_w != meta_w || decoded_h != meta_h)
{
return Err(TransformError::InvalidInput(format!(
"watermark decoded dimensions ({decoded_w}x{decoded_h}) \
do not match header-declared size ({meta_w}x{meta_h})"
)));
}
let mut wm_rgba = wm_image.to_rgba8();
let opacity_scale = f32::from(watermark.opacity) / 100.0;
for pixel in wm_rgba.pixels_mut() {
pixel.0[3] = (f32::from(pixel.0[3]) * opacity_scale) as u8;
}
let (main_w, main_h) = image.dimensions();
let (wm_w, wm_h) = wm_rgba.dimensions();
let margin = watermark.margin;
let (margin_x, margin_y) = match watermark.position {
Position::Center => (0, 0),
Position::Top | Position::Bottom => (0, margin),
Position::Left | Position::Right => (margin, 0),
_ => (margin, margin), };
if u64::from(wm_w) + u64::from(margin_x) > u64::from(main_w)
|| u64::from(wm_h) + u64::from(margin_y) > u64::from(main_h)
{
return Err(TransformError::InvalidOptions(
"watermark image is too large for the output dimensions".to_string(),
));
}
let (x, y) = watermark_offset(main_w, main_h, wm_w, wm_h, watermark.position, margin);
let mut canvas = image.to_rgba8();
imageops::overlay(&mut canvas, &wm_rgba, i64::from(x), i64::from(y));
Ok(DynamicImage::ImageRgba8(canvas))
}
fn watermark_offset(
main_w: u32,
main_h: u32,
wm_w: u32,
wm_h: u32,
position: Position,
margin: u32,
) -> (u32, u32) {
match position {
Position::TopLeft => (margin, margin),
Position::Top => ((main_w.saturating_sub(wm_w)) / 2, margin),
Position::TopRight => (main_w.saturating_sub(wm_w).saturating_sub(margin), margin),
Position::Left => (margin, (main_h.saturating_sub(wm_h)) / 2),
Position::Center => (
(main_w.saturating_sub(wm_w)) / 2,
(main_h.saturating_sub(wm_h)) / 2,
),
Position::Right => (
main_w.saturating_sub(wm_w).saturating_sub(margin),
(main_h.saturating_sub(wm_h)) / 2,
),
Position::BottomLeft => (margin, main_h.saturating_sub(wm_h).saturating_sub(margin)),
Position::Bottom => (
(main_w.saturating_sub(wm_w)) / 2,
main_h.saturating_sub(wm_h).saturating_sub(margin),
),
Position::BottomRight => (
main_w.saturating_sub(wm_w).saturating_sub(margin),
main_h.saturating_sub(wm_h).saturating_sub(margin),
),
}
}
fn scale_dimension(source: u32, target: u32, reference: u32) -> u32 {
let scaled = ((f64::from(source) * f64::from(target)) / f64::from(reference)).round();
scaled.max(1.0) as u32
}
fn pad_to_box(
image: DynamicImage,
target_width: u32,
target_height: u32,
position: Position,
background: Option<Rgba8>,
output_format: MediaType,
) -> DynamicImage {
let resized = image.to_rgba8();
let (content_width, content_height) = resized.dimensions();
let fill = background_pixel(background, output_format);
let mut canvas = RgbaImage::from_pixel(target_width, target_height, fill);
let (x, y) = position_offset(
target_width,
target_height,
content_width,
content_height,
position,
);
imageops::overlay(&mut canvas, &resized, i64::from(x), i64::from(y));
DynamicImage::ImageRgba8(canvas)
}
fn cover_to_box(
image: DynamicImage,
target_width: u32,
target_height: u32,
position: Position,
background: Option<Rgba8>,
output_format: MediaType,
) -> DynamicImage {
let (original_width, original_height) = image.dimensions();
let scale = f64::max(
f64::from(target_width) / f64::from(original_width),
f64::from(target_height) / f64::from(original_height),
);
let resized_width = (f64::from(original_width) * scale).ceil().max(1.0) as u32;
let resized_height = (f64::from(original_height) * scale).ceil().max(1.0) as u32;
let resized = image
.resize_exact(resized_width, resized_height, FilterType::Lanczos3)
.to_rgba8();
if resized_width == target_width && resized_height == target_height {
return DynamicImage::ImageRgba8(resized);
}
let fill = background_pixel(background, output_format);
let mut canvas = RgbaImage::from_pixel(target_width, target_height, fill);
let (crop_x, crop_y) = position_offset(
resized_width,
resized_height,
target_width,
target_height,
position,
);
let cropped =
imageops::crop_imm(&resized, crop_x, crop_y, target_width, target_height).to_image();
imageops::overlay(&mut canvas, &cropped, 0, 0);
DynamicImage::ImageRgba8(canvas)
}
fn position_offset(
container_width: u32,
container_height: u32,
content_width: u32,
content_height: u32,
position: Position,
) -> (u32, u32) {
let horizontal_space = container_width.saturating_sub(content_width);
let vertical_space = container_height.saturating_sub(content_height);
let x = match position {
Position::Center | Position::Top | Position::Bottom => horizontal_space / 2,
Position::Left | Position::TopLeft | Position::BottomLeft => 0,
Position::Right | Position::TopRight | Position::BottomRight => horizontal_space,
};
let y = match position {
Position::Center | Position::Left | Position::Right => vertical_space / 2,
Position::Top | Position::TopLeft | Position::TopRight => 0,
Position::Bottom | Position::BottomLeft | Position::BottomRight => vertical_space,
};
(x, y)
}
fn background_pixel(background: Option<Rgba8>, output_format: MediaType) -> Rgba<u8> {
match background {
Some(color) => Rgba([color.r, color.g, color.b, color.a]),
None if matches!(
output_format,
MediaType::Jpeg | MediaType::Avif | MediaType::Bmp
) =>
{
Rgba([255, 255, 255, 255])
}
None => Rgba([0, 0, 0, 0]),
}
}
fn try_passthrough_lossless_optimization(
normalized: &NormalizedTransformRequest,
) -> Result<Option<TransformResult>, TransformError> {
if normalized.options.optimize != OptimizeMode::Lossless {
return Ok(None);
}
match normalized.options.format {
MediaType::Jpeg => {
if !is_passthrough_lossless_request(normalized) {
return Err(TransformError::CapabilityMissing(
"lossless JPEG optimization is only supported when no pixel transforms are applied".to_string(),
));
}
let bytes = optimize_jpeg_bytes_losslessly(
&normalized.input.bytes,
normalized.options.metadata_policy,
)?;
Ok(Some(TransformResult {
artifact: Artifact::new(
bytes,
normalized.options.format,
normalized.input.metadata.clone(),
),
warnings: Vec::new(),
}))
}
MediaType::Avif => Err(TransformError::CapabilityMissing(
"lossless optimization is not implemented for avif output".to_string(),
)),
_ => Ok(None),
}
}
fn is_passthrough_lossless_request(normalized: &NormalizedTransformRequest) -> bool {
normalized.input.media_type == normalized.options.format
&& normalized.options.width.is_none()
&& normalized.options.height.is_none()
&& normalized.options.quality.is_none()
&& normalized.options.background.is_none()
&& normalized.options.rotate == Rotation::Deg0
&& normalized.options.crop.is_none()
&& normalized.options.blur.is_none()
&& normalized.options.sharpen.is_none()
&& normalized.watermark.is_none()
&& (!normalized.options.auto_orient || jpeg_auto_orientation_is_noop(&normalized.input))
}
fn jpeg_auto_orientation_is_noop(input: &Artifact) -> bool {
if input.media_type != MediaType::Jpeg {
return true;
}
let mut cursor = Cursor::new(&input.bytes);
let Ok(exif) = Reader::new().read_from_container(&mut cursor) else {
return true;
};
let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) else {
return true;
};
matches!(first_orientation_value(&field.value), None | Some(0 | 1))
}
fn optimize_jpeg_bytes_losslessly(
bytes: &[u8],
metadata_policy: MetadataPolicy,
) -> Result<Vec<u8>, TransformError> {
if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
let mut output = Vec::with_capacity(bytes.len());
output.extend_from_slice(&bytes[..2]);
let mut index = 2usize;
while index + 1 < bytes.len() {
if bytes[index] != 0xFF {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
let mut marker_index = index + 1;
while marker_index < bytes.len() && bytes[marker_index] == 0xFF {
marker_index += 1;
}
if marker_index >= bytes.len() {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
let marker = bytes[marker_index];
index = marker_index + 1;
match marker {
0xD9 => {
output.extend_from_slice(&[0xFF, marker]);
return Ok(output);
}
0xDA => {
let segment_end = jpeg_segment_end(bytes, index)?;
if !bytes[segment_end..]
.windows(2)
.any(|window| window == [0xFF, 0xD9])
{
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
output.extend_from_slice(&[0xFF, marker]);
output.extend_from_slice(&bytes[index..segment_end]);
output.extend_from_slice(&bytes[segment_end..]);
return Ok(output);
}
0x01 | 0xD0..=0xD7 => {
output.extend_from_slice(&[0xFF, marker]);
}
_ => {
let segment_end = jpeg_segment_end(bytes, index)?;
let payload = &bytes[index + 2..segment_end];
if should_keep_jpeg_segment(marker, payload, metadata_policy) {
output.extend_from_slice(&[0xFF, marker]);
output.extend_from_slice(&bytes[index..segment_end]);
}
index = segment_end;
}
}
}
Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
))
}
fn jpeg_segment_end(bytes: &[u8], index: usize) -> Result<usize, TransformError> {
if index + 2 > bytes.len() {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
let length = u16::from_be_bytes([bytes[index], bytes[index + 1]]) as usize;
if length < 2 {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
let end = index + length;
if end > bytes.len() {
return Err(TransformError::InvalidInput(
"input is not a valid JPEG bitstream".to_string(),
));
}
Ok(end)
}
fn should_keep_jpeg_segment(marker: u8, payload: &[u8], metadata_policy: MetadataPolicy) -> bool {
match marker {
0xE0 | 0xEE => true,
0xE1..=0xEF | 0xFE => match metadata_policy {
MetadataPolicy::KeepAll => true,
MetadataPolicy::PreserveIcc => marker == 0xE2 && payload.starts_with(b"ICC_PROFILE\0"),
MetadataPolicy::PreserveExif => marker == 0xE1 && payload.starts_with(b"Exif\0\0"),
MetadataPolicy::StripAll => false,
},
_ => true,
}
}
struct EncodedOutput {
bytes: Vec<u8>,
used_lossy_webp: bool,
}
#[derive(Clone, Copy)]
struct EncodeDeadline {
start: Option<Instant>,
deadline: Option<Duration>,
}
impl EncodeDeadline {
fn check(self, stage: &'static str) -> Result<(), TransformError> {
if let (Some(start), Some(limit)) = (self.start, self.deadline) {
check_deadline(start.elapsed(), limit, stage)?;
}
Ok(())
}
}
fn encode_output(
image: &DynamicImage,
media_type: MediaType,
options: &NormalizedTransformOptions,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
match options.optimize {
OptimizeMode::None => {
encode_baseline_output(image, media_type, options.quality, retained_metadata)
}
OptimizeMode::Auto => {
encode_auto_output(image, media_type, options, retained_metadata, deadline)
}
OptimizeMode::Lossless => {
encode_lossless_optimized_output(image, media_type, retained_metadata, deadline)
}
OptimizeMode::Lossy => {
encode_lossy_optimized_output(image, media_type, options, retained_metadata, deadline)
}
}
}
fn encode_auto_output(
image: &DynamicImage,
media_type: MediaType,
options: &NormalizedTransformOptions,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
let baseline = encode_baseline_output(image, media_type, options.quality, retained_metadata)?;
deadline.check("encode auto baseline")?;
let optimized = match media_type {
MediaType::Png => encode_png_optimized(image, retained_metadata, deadline)?,
MediaType::Jpeg | MediaType::Webp | MediaType::Avif => {
match encode_lossy_optimized_output(
image,
media_type,
options,
retained_metadata,
deadline,
) {
Ok(output) => output,
Err(TransformError::CapabilityMissing(_)) if media_type == MediaType::Webp => {
return Ok(baseline);
}
Err(error) => return Err(error),
}
}
_ => return Ok(baseline),
};
if optimized.bytes.len() < baseline.bytes.len() {
Ok(optimized)
} else {
Ok(baseline)
}
}
fn encode_lossless_optimized_output(
image: &DynamicImage,
media_type: MediaType,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
match media_type {
MediaType::Png => encode_png_optimized(image, retained_metadata, deadline),
MediaType::Webp => {
deadline.check("encode lossless webp")?;
encode_webp_lossless(image, retained_metadata)
}
MediaType::Jpeg => Err(TransformError::CapabilityMissing(
"lossless JPEG optimization is only supported when no pixel transforms are applied"
.to_string(),
)),
MediaType::Avif => Err(TransformError::CapabilityMissing(
"lossless optimization is not implemented for avif output".to_string(),
)),
_ => Err(TransformError::InvalidOptions(format!(
"optimization is not supported for {} output",
media_type.as_name()
))),
}
}
fn encode_lossy_optimized_output(
image: &DynamicImage,
media_type: MediaType,
options: &NormalizedTransformOptions,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
let target = options.target_quality.or_else(|| {
if options.quality.is_none() {
default_lossy_target_quality(media_type)
} else {
None
}
});
let max_quality = options.quality.unwrap_or(100);
if let Some(target) = target {
encode_lossy_with_target(
image,
media_type,
target,
max_quality,
retained_metadata,
deadline,
)
} else {
let quality = options
.quality
.unwrap_or_else(|| default_lossy_quality(media_type));
encode_lossy_with_quality(
image,
media_type,
quality,
retained_metadata,
true,
deadline,
)
}
}
fn default_lossy_quality(media_type: MediaType) -> u8 {
match media_type {
MediaType::Jpeg => 76,
MediaType::Webp => 75,
MediaType::Avif => 68,
_ => 80,
}
}
fn encode_lossy_with_target(
image: &DynamicImage,
media_type: MediaType,
target: TargetQuality,
max_quality: u8,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
let mut low = 1u8;
let mut high = max_quality.max(1);
let mut best: Option<EncodedOutput> = None;
while low <= high {
let mid = low + (high - low) / 2;
let candidate =
encode_lossy_with_quality(image, media_type, mid, retained_metadata, true, deadline)?;
deadline.check("encode lossy optimization candidate")?;
let score =
measure_quality_metric(image, &candidate.bytes, media_type, target.metric, deadline)?;
deadline.check("measure lossy optimization quality")?;
if score >= target.value {
best = Some(candidate);
if mid == 1 {
break;
}
high = mid - 1;
} else {
low = mid.saturating_add(1);
}
}
if let Some(best) = best {
Ok(best)
} else {
encode_lossy_with_quality(
image,
media_type,
max_quality.max(1),
retained_metadata,
true,
deadline,
)
}
}
fn measure_quality_metric(
reference: &DynamicImage,
encoded_bytes: &[u8],
media_type: MediaType,
metric: QualityMetric,
deadline: EncodeDeadline,
) -> Result<f32, TransformError> {
let decoded = decode_encoded_output(encoded_bytes, media_type)?;
deadline.check("decode lossy optimization candidate")?;
match metric {
QualityMetric::Ssim => compute_ssim(reference, &decoded, deadline),
QualityMetric::Psnr => compute_psnr(reference, &decoded, deadline),
}
}
fn decode_encoded_output(
bytes: &[u8],
media_type: MediaType,
) -> Result<DynamicImage, TransformError> {
let artifact = sniff_artifact(RawArtifact::new(bytes.to_vec(), Some(media_type)))?;
decode_input(&artifact)
}
fn compute_psnr(
reference: &DynamicImage,
candidate: &DynamicImage,
deadline: EncodeDeadline,
) -> Result<f32, TransformError> {
let lhs = reference.to_rgba8();
let rhs = candidate.to_rgba8();
if lhs.dimensions() != rhs.dimensions() {
return Err(TransformError::EncodeFailed(
"quality metric comparison produced mismatched dimensions".to_string(),
));
}
let mut squared_error = 0f64;
for (index, (left, right)) in lhs.pixels().zip(rhs.pixels()).enumerate() {
for (a, b) in left.0.iter().zip(right.0.iter()) {
let delta = f64::from(*a) - f64::from(*b);
squared_error += delta * delta;
}
if index.is_multiple_of(16_384) {
deadline.check("measure lossy optimization psnr")?;
}
}
let sample_count = f64::from(lhs.width()) * f64::from(lhs.height()) * 4.0;
let mse = squared_error / sample_count.max(1.0);
if mse == 0.0 {
return Ok(f32::INFINITY);
}
Ok((10.0 * ((255.0f64 * 255.0) / mse).log10()) as f32)
}
fn compute_ssim(
reference: &DynamicImage,
candidate: &DynamicImage,
deadline: EncodeDeadline,
) -> Result<f32, TransformError> {
let lhs = reference.to_luma8();
let rhs = candidate.to_luma8();
if lhs.dimensions() != rhs.dimensions() {
return Err(TransformError::EncodeFailed(
"quality metric comparison produced mismatched dimensions".to_string(),
));
}
let sample_count = f64::from(lhs.width()) * f64::from(lhs.height());
if sample_count <= 0.0 {
return Ok(1.0);
}
let mut mean_x = 0f64;
let mut mean_y = 0f64;
for (index, (left, right)) in lhs.pixels().zip(rhs.pixels()).enumerate() {
mean_x += f64::from(left.0[0]);
mean_y += f64::from(right.0[0]);
if index.is_multiple_of(16_384) {
deadline.check("measure lossy optimization ssim mean")?;
}
}
mean_x /= sample_count;
mean_y /= sample_count;
let mut variance_x = 0f64;
let mut variance_y = 0f64;
let mut covariance = 0f64;
for (index, (left, right)) in lhs.pixels().zip(rhs.pixels()).enumerate() {
let x = f64::from(left.0[0]) - mean_x;
let y = f64::from(right.0[0]) - mean_y;
variance_x += x * x;
variance_y += y * y;
covariance += x * y;
if index.is_multiple_of(16_384) {
deadline.check("measure lossy optimization ssim variance")?;
}
}
variance_x /= sample_count;
variance_y /= sample_count;
covariance /= sample_count;
let c1 = (0.01 * 255.0f64).powi(2);
let c2 = (0.03 * 255.0f64).powi(2);
let numerator = (2.0 * mean_x * mean_y + c1) * (2.0 * covariance + c2);
let denominator = (mean_x.powi(2) + mean_y.powi(2) + c1) * (variance_x + variance_y + c2);
if denominator == 0.0 {
return Ok(1.0);
}
Ok((numerator / denominator).clamp(0.0, 1.0) as f32)
}
fn encode_baseline_output(
image: &DynamicImage,
media_type: MediaType,
quality: Option<u8>,
retained_metadata: Option<&RetainedMetadata>,
) -> Result<EncodedOutput, TransformError> {
match media_type {
MediaType::Jpeg => Ok(EncodedOutput {
bytes: encode_jpeg(image, quality.unwrap_or(80), retained_metadata)?,
used_lossy_webp: false,
}),
MediaType::Png => Ok(EncodedOutput {
bytes: encode_png(
image,
retained_metadata,
PngCompressionType::Default,
PngFilterType::Adaptive,
)?,
used_lossy_webp: false,
}),
MediaType::Webp => {
if let Some(quality) = quality {
Ok(EncodedOutput {
bytes: encode_webp_lossy_bytes(image, quality)?,
used_lossy_webp: true,
})
} else {
encode_webp_lossless(image, retained_metadata)
}
}
MediaType::Avif => Ok(EncodedOutput {
bytes: encode_avif(image, quality.unwrap_or(80), 4, retained_metadata)?,
used_lossy_webp: false,
}),
MediaType::Bmp => Ok(EncodedOutput {
bytes: encode_bmp(image)?,
used_lossy_webp: false,
}),
MediaType::Tiff => Ok(EncodedOutput {
bytes: encode_tiff(image)?,
used_lossy_webp: false,
}),
MediaType::Svg => Err(TransformError::EncodeFailed(
"SVG encoding should be handled by transform_svg".into(),
)),
}
}
fn encode_png_optimized(
image: &DynamicImage,
retained_metadata: Option<&RetainedMetadata>,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
let strategies = [
(PngCompressionType::Best, PngFilterType::Adaptive),
(PngCompressionType::Best, PngFilterType::Paeth),
(PngCompressionType::Best, PngFilterType::Sub),
(PngCompressionType::Level(9), PngFilterType::Adaptive),
(PngCompressionType::Level(9), PngFilterType::Paeth),
];
let mut best = encode_png(
image,
retained_metadata,
PngCompressionType::Default,
PngFilterType::Adaptive,
)?;
deadline.check("encode png optimization baseline")?;
for (compression, filter) in strategies {
let candidate = encode_png(image, retained_metadata, compression, filter)?;
deadline.check("encode png optimization candidate")?;
if candidate.len() < best.len() {
best = candidate;
}
}
Ok(EncodedOutput {
bytes: best,
used_lossy_webp: false,
})
}
fn encode_lossy_with_quality(
image: &DynamicImage,
media_type: MediaType,
quality: u8,
retained_metadata: Option<&RetainedMetadata>,
optimized: bool,
deadline: EncodeDeadline,
) -> Result<EncodedOutput, TransformError> {
match media_type {
MediaType::Jpeg => {
let bytes = encode_jpeg(image, quality, retained_metadata)?;
deadline.check("encode lossy jpeg")?;
Ok(EncodedOutput {
bytes,
used_lossy_webp: false,
})
}
MediaType::Webp => {
if optimized && retained_metadata.is_some_and(|metadata| !metadata.is_empty()) {
return Err(TransformError::CapabilityMissing(
"lossy WebP optimization cannot preserve metadata".to_string(),
));
}
let bytes = encode_webp_lossy_bytes(image, quality)?;
deadline.check("encode lossy webp")?;
Ok(EncodedOutput {
bytes,
used_lossy_webp: true,
})
}
MediaType::Avif => {
let bytes = encode_avif(
image,
quality,
if optimized { 2 } else { 4 },
retained_metadata,
)?;
deadline.check("encode lossy avif")?;
Ok(EncodedOutput {
bytes,
used_lossy_webp: false,
})
}
_ => Err(TransformError::InvalidOptions(format!(
"lossy optimization is not supported for {} output",
media_type.as_name()
))),
}
}
fn encode_jpeg(
image: &DynamicImage,
quality: u8,
retained_metadata: Option<&RetainedMetadata>,
) -> Result<Vec<u8>, TransformError> {
let mut bytes = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut bytes, quality);
if let Some(retained_metadata) = retained_metadata {
if let Some(icc_profile) = &retained_metadata.icc_profile {
encoder
.set_icc_profile(icc_profile.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
if let Some(exif) = &retained_metadata.exif_metadata {
encoder
.set_exif_metadata(exif.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
}
let rgb = image.to_rgb8();
encoder
.write_image(&rgb, rgb.width(), rgb.height(), ColorType::Rgb8.into())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
Ok(bytes)
}
fn encode_png(
image: &DynamicImage,
retained_metadata: Option<&RetainedMetadata>,
compression: PngCompressionType,
filter: PngFilterType,
) -> Result<Vec<u8>, TransformError> {
let mut bytes = Vec::new();
let mut encoder = PngEncoder::new_with_quality(&mut bytes, compression, filter);
if let Some(retained_metadata) = retained_metadata {
if let Some(icc_profile) = &retained_metadata.icc_profile {
encoder
.set_icc_profile(icc_profile.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
if let Some(exif) = &retained_metadata.exif_metadata {
encoder
.set_exif_metadata(exif.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
}
let rgba = image.to_rgba8();
encoder
.write_image(&rgba, rgba.width(), rgba.height(), ColorType::Rgba8.into())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
Ok(bytes)
}
fn encode_webp_lossless(
image: &DynamicImage,
retained_metadata: Option<&RetainedMetadata>,
) -> Result<EncodedOutput, TransformError> {
let mut bytes = Vec::new();
let rgba = image.to_rgba8();
let mut encoder = WebPEncoder::new_lossless(&mut bytes);
if let Some(retained_metadata) = retained_metadata {
if let Some(icc_profile) = &retained_metadata.icc_profile {
encoder
.set_icc_profile(icc_profile.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
if let Some(exif) = &retained_metadata.exif_metadata {
encoder
.set_exif_metadata(exif.clone())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
}
}
encoder
.write_image(&rgba, rgba.width(), rgba.height(), ColorType::Rgba8.into())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
Ok(EncodedOutput {
bytes,
used_lossy_webp: false,
})
}
fn encode_webp_lossy_bytes(image: &DynamicImage, quality: u8) -> Result<Vec<u8>, TransformError> {
#[cfg(feature = "webp-lossy")]
{
let rgba = image.to_rgba8();
let lossy_encoder = webp::Encoder::from_rgba(rgba.as_ref(), rgba.width(), rgba.height());
Ok(lossy_encoder.encode(f32::from(quality)).to_vec())
}
#[cfg(not(feature = "webp-lossy"))]
{
let _ = (image, quality);
Err(TransformError::CapabilityMissing(
"lossy WebP encoding is not enabled in this build".to_string(),
))
}
}
fn encode_avif(
image: &DynamicImage,
quality: u8,
speed: u8,
retained_metadata: Option<&RetainedMetadata>,
) -> Result<Vec<u8>, TransformError> {
#[cfg(feature = "avif")]
{
if retained_metadata.is_some_and(|metadata| !metadata.is_empty()) {
return Err(TransformError::CapabilityMissing(
"metadata retention is not implemented for avif output".to_string(),
));
}
let mut bytes = Vec::new();
let rgba = image.to_rgba8();
let encoder = AvifEncoder::new_with_speed_quality(&mut bytes, speed, quality);
encoder
.write_image(&rgba, rgba.width(), rgba.height(), ColorType::Rgba8.into())
.map_err(|error| TransformError::EncodeFailed(error.to_string()))?;
Ok(bytes)
}
#[cfg(not(feature = "avif"))]
{
let _ = (image, quality, speed, retained_metadata);
Err(TransformError::CapabilityMissing(
"AVIF encoding is not enabled in this build".to_string(),
))
}
}
fn encode_bmp(image: &DynamicImage) -> Result<Vec<u8>, TransformError> {
let mut bytes = Vec::new();
let rgba = image.to_rgba8();
image::codecs::bmp::BmpEncoder::new(&mut bytes)
.write_image(&rgba, rgba.width(), rgba.height(), ColorType::Rgba8.into())
.map_err(|error: image::ImageError| TransformError::EncodeFailed(error.to_string()))?;
Ok(bytes)
}
fn encode_tiff(image: &DynamicImage) -> Result<Vec<u8>, TransformError> {
let rgba = image.to_rgba8();
let mut cursor = Cursor::new(Vec::new());
image::codecs::tiff::TiffEncoder::new(&mut cursor)
.write_image(&rgba, rgba.width(), rgba.height(), ColorType::Rgba8.into())
.map_err(|error: image::ImageError| TransformError::EncodeFailed(error.to_string()))?;
Ok(cursor.into_inner())
}
fn inject_metadata(
mut encoded: Vec<u8>,
format: MediaType,
metadata: &RetainedMetadata,
warnings: &mut Vec<TransformWarning>,
) -> Vec<u8> {
match format {
MediaType::Jpeg => {
if let Some(iptc) = &metadata.iptc_metadata
&& let Ok(result) = inject_jpeg_iptc(&encoded, iptc)
{
encoded = result;
warnings.retain(|w| {
!matches!(w, TransformWarning::MetadataDropped(MetadataKind::Iptc))
});
}
if let Some(xmp) = &metadata.xmp_metadata
&& let Ok(result) = inject_jpeg_xmp(&encoded, xmp)
{
encoded = result;
warnings
.retain(|w| !matches!(w, TransformWarning::MetadataDropped(MetadataKind::Xmp)));
}
}
MediaType::Png => {
if let Some(xmp) = &metadata.xmp_metadata
&& let Ok(result) = inject_png_xmp(&encoded, xmp)
{
encoded = result;
warnings
.retain(|w| !matches!(w, TransformWarning::MetadataDropped(MetadataKind::Xmp)));
}
}
_ => {
}
}
encoded
}
fn inject_jpeg_xmp(encoded: &[u8], xmp: &[u8]) -> Result<Vec<u8>, TransformError> {
const XMP_NAMESPACE: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
if encoded.len() < 2 || encoded[0] != 0xFF || encoded[1] != 0xD8 {
return Err(TransformError::EncodeFailed(
"cannot inject XMP: output is not a valid JPEG".into(),
));
}
let data_len = XMP_NAMESPACE.len() + xmp.len();
let segment_len = u16::try_from(data_len + 2).map_err(|_| {
TransformError::EncodeFailed(
"XMP payload exceeds the JPEG APP1 segment size limit (64KB)".into(),
)
})?;
let mut result = Vec::with_capacity(encoded.len() + 4 + data_len);
result.extend_from_slice(&encoded[..2]); result.push(0xFF);
result.push(0xE1); result.extend_from_slice(&segment_len.to_be_bytes());
result.extend_from_slice(XMP_NAMESPACE);
result.extend_from_slice(xmp);
result.extend_from_slice(&encoded[2..]); Ok(result)
}
fn inject_jpeg_iptc(encoded: &[u8], iptc: &[u8]) -> Result<Vec<u8>, TransformError> {
const PHOTOSHOP_NAMESPACE: &[u8] = b"Photoshop 3.0\0";
const BIM_SIGNATURE: &[u8] = b"8BIM";
const IPTC_RESOURCE_TYPE: u16 = 0x0404;
if encoded.len() < 2 || encoded[0] != 0xFF || encoded[1] != 0xD8 {
return Err(TransformError::EncodeFailed(
"cannot inject IPTC: output is not a valid JPEG".into(),
));
}
let resource_header_len = BIM_SIGNATURE.len() + 2 + 1 + 1 + 4; let iptc_padded_len = if iptc.len().is_multiple_of(2) {
iptc.len()
} else {
iptc.len() + 1
};
let resource_block_len = resource_header_len + iptc_padded_len;
let data_len = PHOTOSHOP_NAMESPACE.len() + resource_block_len;
let segment_len = u16::try_from(data_len + 2).map_err(|_| {
TransformError::EncodeFailed(
"IPTC payload exceeds the JPEG APP13 segment size limit (64KB)".into(),
)
})?;
let mut result = Vec::with_capacity(encoded.len() + 4 + data_len);
result.extend_from_slice(&encoded[..2]); result.push(0xFF);
result.push(0xED); result.extend_from_slice(&segment_len.to_be_bytes());
result.extend_from_slice(PHOTOSHOP_NAMESPACE);
result.extend_from_slice(BIM_SIGNATURE);
result.extend_from_slice(&IPTC_RESOURCE_TYPE.to_be_bytes());
result.push(0x00); result.push(0x00); result.extend_from_slice(&(iptc.len() as u32).to_be_bytes());
result.extend_from_slice(iptc);
if !iptc.len().is_multiple_of(2) {
result.push(0x00); }
result.extend_from_slice(&encoded[2..]); Ok(result)
}
fn inject_png_xmp(encoded: &[u8], xmp: &[u8]) -> Result<Vec<u8>, TransformError> {
const PNG_SIGNATURE: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
const ITXT_TYPE: &[u8] = b"iTXt";
const XMP_KEYWORD: &[u8] = b"XML:com.adobe.xmp";
if encoded.len() < 8 || &encoded[..8] != PNG_SIGNATURE {
return Err(TransformError::EncodeFailed(
"cannot inject XMP: output is not a valid PNG".into(),
));
}
if encoded.len() < 8 + 4 + 4 {
return Err(TransformError::EncodeFailed(
"cannot inject XMP: PNG is too short to contain IHDR".into(),
));
}
let ihdr_data_len =
u32::from_be_bytes([encoded[8], encoded[9], encoded[10], encoded[11]]) as usize;
let ihdr_end = 8 + 4 + 4 + ihdr_data_len + 4; if encoded.len() < ihdr_end {
return Err(TransformError::EncodeFailed(
"cannot inject XMP: PNG IHDR chunk is truncated".into(),
));
}
let mut chunk_data = Vec::with_capacity(XMP_KEYWORD.len() + 5 + xmp.len());
chunk_data.extend_from_slice(XMP_KEYWORD);
chunk_data.push(0x00); chunk_data.push(0x00); chunk_data.push(0x00); chunk_data.push(0x00); chunk_data.push(0x00); chunk_data.extend_from_slice(xmp);
let chunk_data_len = chunk_data.len() as u32;
let mut crc_input = Vec::with_capacity(4 + chunk_data.len());
crc_input.extend_from_slice(ITXT_TYPE);
crc_input.extend_from_slice(&chunk_data);
let crc = png_crc32(&crc_input);
let chunk_total = 4 + 4 + chunk_data.len() + 4;
let mut result = Vec::with_capacity(encoded.len() + chunk_total);
result.extend_from_slice(&encoded[..ihdr_end]); result.extend_from_slice(&chunk_data_len.to_be_bytes());
result.extend_from_slice(ITXT_TYPE);
result.extend_from_slice(&chunk_data);
result.extend_from_slice(&crc.to_be_bytes());
result.extend_from_slice(&encoded[ihdr_end..]); Ok(result)
}
fn png_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFF_FFFF
}
#[derive(Debug, Default)]
struct RetainedMetadata {
exif_metadata: Option<Vec<u8>>,
icc_profile: Option<Vec<u8>>,
xmp_metadata: Option<Vec<u8>>,
iptc_metadata: Option<Vec<u8>>,
}
impl RetainedMetadata {
fn is_empty(&self) -> bool {
self.exif_metadata.is_none()
&& self.icc_profile.is_none()
&& self.xmp_metadata.is_none()
&& self.iptc_metadata.is_none()
}
fn retain_exif_only(mut self) -> Self {
self.icc_profile = None;
self.xmp_metadata = None;
self.iptc_metadata = None;
self
}
fn retain_icc_only(mut self) -> Self {
self.exif_metadata = None;
self.xmp_metadata = None;
self.iptc_metadata = None;
self
}
fn retain_supported_keep_all(mut self, output_format: MediaType) -> Self {
match output_format {
MediaType::Jpeg => {
}
MediaType::Png => {
self.iptc_metadata = None;
}
_ => {
self.xmp_metadata = None;
self.iptc_metadata = None;
}
}
self
}
}
fn extract_retained_metadata(
input: &Artifact,
metadata_policy: MetadataPolicy,
auto_orient: bool,
output_format: MediaType,
) -> Result<(Option<RetainedMetadata>, Vec<TransformWarning>), TransformError> {
let mut warnings = Vec::new();
if matches!(metadata_policy, MetadataPolicy::StripAll) {
return Ok((None, warnings));
}
let mut metadata = read_input_metadata(input)?;
if let Some(exif_chunk) = metadata.exif_metadata.as_mut()
&& auto_orient
&& matches!(input.media_type, MediaType::Jpeg)
{
let _ = Orientation::remove_from_exif_chunk(exif_chunk);
}
let metadata = match metadata_policy {
MetadataPolicy::StripAll => return Ok((None, warnings)),
MetadataPolicy::PreserveIcc => metadata.retain_icc_only(),
MetadataPolicy::PreserveExif => metadata.retain_exif_only(),
MetadataPolicy::KeepAll => {
if metadata.xmp_metadata.is_some()
&& !matches!(output_format, MediaType::Jpeg | MediaType::Png)
{
warnings.push(TransformWarning::MetadataDropped(MetadataKind::Xmp));
}
if metadata.iptc_metadata.is_some() && !matches!(output_format, MediaType::Jpeg) {
warnings.push(TransformWarning::MetadataDropped(MetadataKind::Iptc));
}
metadata.retain_supported_keep_all(output_format)
}
};
if matches!(output_format, MediaType::Avif) && !metadata.is_empty() {
return Err(TransformError::CapabilityMissing(
"metadata retention is not implemented for avif output".to_string(),
));
}
if metadata.is_empty() {
return Ok((None, warnings));
}
Ok((Some(metadata), warnings))
}
fn read_input_metadata(input: &Artifact) -> Result<RetainedMetadata, TransformError> {
match input.media_type {
MediaType::Jpeg => {
let mut decoder = JpegDecoder::new(Cursor::new(&input.bytes))
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?;
Ok(RetainedMetadata {
exif_metadata: decoder
.exif_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
icc_profile: decoder
.icc_profile()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
xmp_metadata: decoder
.xmp_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
iptc_metadata: decoder
.iptc_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
})
}
MediaType::Png => {
let mut decoder = PngDecoder::new(Cursor::new(&input.bytes))
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?;
Ok(RetainedMetadata {
exif_metadata: decoder
.exif_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
icc_profile: decoder
.icc_profile()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
xmp_metadata: decoder
.xmp_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
iptc_metadata: decoder
.iptc_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
})
}
MediaType::Webp => {
let mut decoder = WebPDecoder::new(Cursor::new(&input.bytes))
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?;
Ok(RetainedMetadata {
exif_metadata: decoder
.exif_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
icc_profile: decoder
.icc_profile()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
xmp_metadata: decoder
.xmp_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
iptc_metadata: decoder
.iptc_metadata()
.map_err(|error| TransformError::DecodeFailed(error.to_string()))?,
})
}
MediaType::Avif | MediaType::Svg | MediaType::Bmp | MediaType::Tiff => {
Ok(RetainedMetadata::default())
}
}
}
fn output_has_alpha(image: &DynamicImage, media_type: MediaType) -> bool {
match media_type {
MediaType::Jpeg => false,
MediaType::Png
| MediaType::Webp
| MediaType::Avif
| MediaType::Svg
| MediaType::Bmp
| MediaType::Tiff => image.color().has_alpha(),
}
}
#[cfg(test)]
mod tests {
use super::{apply_exif_orientation, optimize_jpeg_bytes_losslessly, transform_raster};
use crate::core::{
Artifact, ArtifactMetadata, Fit, MediaType, MetadataKind, MetadataPolicy, OptimizeMode,
Position, Rotation, TransformOptions, TransformRequest, TransformWarning, WatermarkInput,
};
use crate::{RawArtifact, Rgba8, TransformError, sniff_artifact};
use image::codecs::jpeg::JpegDecoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngDecoder;
use image::codecs::png::PngEncoder;
use image::codecs::webp::WebPDecoder;
use image::codecs::webp::WebPEncoder;
use image::metadata::Orientation;
use image::{
ColorType, DynamicImage, GenericImageView, ImageDecoder, ImageEncoder, ImageFormat, Rgba,
RgbaImage,
};
use std::io::Cursor;
fn png_artifact(width: u32, height: u32, fill: Rgba<u8>) -> Artifact {
let image = RgbaImage::from_pixel(width, height, fill);
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&image, width, height, ColorType::Rgba8.into())
.expect("encode png");
Artifact::new(
bytes,
MediaType::Png,
ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(fill[3] < u8::MAX),
},
)
}
fn jpeg_artifact_with_metadata(
width: u32,
height: u32,
orientation: Option<u16>,
icc_profile: Option<&[u8]>,
) -> Artifact {
let image = image::RgbImage::from_pixel(width, height, image::Rgb([10, 20, 30]));
let mut bytes = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut bytes, 80);
if let Some(orientation) = orientation {
let exif = vec![
0x49,
0x49,
0x2A,
0x00,
0x08,
0x00,
0x00,
0x00,
0x01,
0x00,
0x12,
0x01,
0x03,
0x00,
0x01,
0x00,
0x00,
0x00,
orientation as u8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
encoder
.set_exif_metadata(exif)
.expect("set jpeg exif metadata");
}
if let Some(icc_profile) = icc_profile {
encoder
.set_icc_profile(icc_profile.to_vec())
.expect("set jpeg icc profile");
}
encoder
.write_image(&image, width, height, ColorType::Rgb8.into())
.expect("encode jpeg");
Artifact::new(
bytes,
MediaType::Jpeg,
ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
)
}
fn png_artifact_with_metadata(
width: u32,
height: u32,
orientation: Option<u16>,
icc_profile: Option<&[u8]>,
) -> Artifact {
let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
let mut encoder = PngEncoder::new(&mut bytes);
if let Some(orientation) = orientation {
let exif = vec![
0x49,
0x49,
0x2A,
0x00,
0x08,
0x00,
0x00,
0x00,
0x01,
0x00,
0x12,
0x01,
0x03,
0x00,
0x01,
0x00,
0x00,
0x00,
orientation as u8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
encoder
.set_exif_metadata(exif)
.expect("set png exif metadata");
}
if let Some(icc_profile) = icc_profile {
encoder
.set_icc_profile(icc_profile.to_vec())
.expect("set png icc profile");
}
encoder
.write_image(&image, width, height, ColorType::Rgba8.into())
.expect("encode png");
Artifact::new(
bytes,
MediaType::Png,
ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
)
}
fn webp_artifact_with_metadata(
width: u32,
height: u32,
orientation: Option<u16>,
icc_profile: Option<&[u8]>,
) -> Artifact {
let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
let mut encoder = WebPEncoder::new_lossless(&mut bytes);
if let Some(orientation) = orientation {
let exif = vec![
0x49,
0x49,
0x2A,
0x00,
0x08,
0x00,
0x00,
0x00,
0x01,
0x00,
0x12,
0x01,
0x03,
0x00,
0x01,
0x00,
0x00,
0x00,
orientation as u8,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
encoder
.set_exif_metadata(exif)
.expect("set webp exif metadata");
}
if let Some(icc_profile) = icc_profile {
encoder
.set_icc_profile(icc_profile.to_vec())
.expect("set webp icc profile");
}
encoder
.write_image(&image, width, height, ColorType::Rgba8.into())
.expect("encode webp");
Artifact::new(
bytes,
MediaType::Webp,
ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
)
}
fn jpeg_with_xmp_iptc() -> Artifact {
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([10, 20, 30]));
let mut base_bytes = Vec::new();
JpegEncoder::new_with_quality(&mut base_bytes, 80)
.write_image(&image, 2, 2, ColorType::Rgb8.into())
.expect("encode jpeg");
let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
let xmp_payload = b"<x:xmpmeta>test</x:xmpmeta>";
let xmp_data_len = xmp_ns.len() + xmp_payload.len();
let xmp_segment_len = (xmp_data_len + 2) as u16;
let mut xmp_segment = vec![0xFF, 0xE1];
xmp_segment.extend_from_slice(&xmp_segment_len.to_be_bytes());
xmp_segment.extend_from_slice(xmp_ns);
xmp_segment.extend_from_slice(xmp_payload);
let iptc_ns = b"Photoshop 3.0\0";
let iptc_payload = b"\x1c\x02\x00\x00\x02OK";
let iptc_data_len = iptc_ns.len() + iptc_payload.len();
let iptc_segment_len = (iptc_data_len + 2) as u16;
let mut iptc_segment = vec![0xFF, 0xED];
iptc_segment.extend_from_slice(&iptc_segment_len.to_be_bytes());
iptc_segment.extend_from_slice(iptc_ns);
iptc_segment.extend_from_slice(iptc_payload);
let mut jpeg_with_metadata = Vec::new();
jpeg_with_metadata.extend_from_slice(&base_bytes[..2]); jpeg_with_metadata.extend_from_slice(&xmp_segment);
jpeg_with_metadata.extend_from_slice(&iptc_segment);
jpeg_with_metadata.extend_from_slice(&base_bytes[2..]); Artifact::new(
jpeg_with_metadata,
MediaType::Jpeg,
ArtifactMetadata {
width: Some(2),
height: Some(2),
..ArtifactMetadata::default()
},
)
}
fn top_left_pixel(bytes: &[u8], format: ImageFormat) -> [u8; 4] {
image::load_from_memory_with_format(bytes, format)
.expect("decode image")
.to_rgba8()
.get_pixel(0, 0)
.0
}
#[test]
fn transform_raster_can_convert_png_to_jpeg() {
let artifact = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
..TransformOptions::default()
},
))
.expect("convert png to jpeg");
assert_eq!(result.artifact.media_type, MediaType::Jpeg);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
assert_eq!(result.artifact.metadata.has_alpha, Some(false));
}
#[test]
fn transform_raster_resizes_with_single_dimension() {
let artifact = png_artifact(4, 2, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
width: Some(8),
..TransformOptions::default()
},
))
.expect("resize with width");
assert_eq!(result.artifact.metadata.width, Some(8));
assert_eq!(result.artifact.metadata.height, Some(4));
}
#[test]
fn transform_raster_can_pad_with_background_for_contain() {
let artifact = png_artifact(4, 2, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
width: Some(8),
height: Some(8),
fit: Some(Fit::Contain),
position: Some(Position::TopLeft),
background: Some(Rgba8 {
r: 255,
g: 0,
b: 0,
a: 255,
}),
..TransformOptions::default()
},
))
.expect("contain with background");
assert_eq!(result.artifact.metadata.width, Some(8));
assert_eq!(result.artifact.metadata.height, Some(8));
assert_eq!(
top_left_pixel(&result.artifact.bytes, ImageFormat::Png),
[10, 20, 30, 255]
);
}
#[test]
fn transform_raster_can_cover_the_target_box() {
let artifact = png_artifact(4, 2, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
width: Some(2),
height: Some(2),
fit: Some(Fit::Cover),
..TransformOptions::default()
},
))
.expect("cover resize");
assert_eq!(result.artifact.metadata.width, Some(2));
assert_eq!(result.artifact.metadata.height, Some(2));
}
#[test]
fn transform_raster_can_rotate_output() {
let artifact = png_artifact(4, 2, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
rotate: Rotation::Deg90,
..TransformOptions::default()
},
))
.expect("rotate image");
assert_eq!(result.artifact.metadata.width, Some(2));
assert_eq!(result.artifact.metadata.height, Some(4));
}
#[test]
fn transform_raster_preserves_exif_and_normalizes_orientation() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(6), None);
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
strip_metadata: false,
preserve_exif: true,
..TransformOptions::default()
},
))
.expect("preserve exif");
let mut decoder =
JpegDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode jpeg");
let exif = decoder
.exif_metadata()
.expect("read jpeg exif")
.expect("retained exif");
assert_eq!(result.artifact.metadata.width, Some(2));
assert_eq!(result.artifact.metadata.height, Some(4));
assert_eq!(
Orientation::from_exif_chunk(&exif),
Some(Orientation::NoTransforms)
);
}
#[test]
fn transform_raster_preserve_exif_drops_icc_profile() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(6), Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
strip_metadata: false,
preserve_exif: true,
..TransformOptions::default()
},
))
.expect("preserve exif only");
let mut decoder =
JpegDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode jpeg");
assert_eq!(decoder.icc_profile().expect("read jpeg icc profile"), None);
}
#[test]
fn transform_raster_preserve_exif_keeps_png_orientation_when_pixels_are_not_auto_oriented() {
let artifact = png_artifact_with_metadata(4, 2, Some(6), None);
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Png),
strip_metadata: false,
preserve_exif: true,
..TransformOptions::default()
},
))
.expect("preserve png exif");
let mut decoder = PngDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode png");
let exif = decoder
.exif_metadata()
.expect("read png exif")
.expect("retained png exif");
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(2));
assert_eq!(
Orientation::from_exif_chunk(&exif),
Some(Orientation::Rotate90)
);
}
#[test]
fn transform_raster_keeps_supported_metadata_when_requested() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(6), Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
strip_metadata: false,
..TransformOptions::default()
},
))
.expect("keep metadata");
let mut decoder =
JpegDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode jpeg");
let exif = decoder
.exif_metadata()
.expect("read jpeg exif")
.expect("retained exif");
let icc_profile = decoder
.icc_profile()
.expect("read jpeg icc")
.expect("retained icc");
assert_eq!(result.artifact.metadata.width, Some(2));
assert_eq!(result.artifact.metadata.height, Some(4));
assert_eq!(
Orientation::from_exif_chunk(&exif),
Some(Orientation::NoTransforms)
);
assert_eq!(icc_profile, b"demo-icc-profile".to_vec());
}
#[test]
fn transform_raster_lossless_jpeg_optimization_strips_metadata_without_reencoding() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(1), Some(b"demo-icc-profile"));
let input_len = artifact.bytes.len();
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
optimize: OptimizeMode::Lossless,
..TransformOptions::default()
},
))
.expect("lossless jpeg optimize");
let mut decoder =
JpegDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode jpeg");
assert!(result.artifact.bytes.len() < input_len);
assert_eq!(decoder.exif_metadata().expect("read jpeg exif"), None);
assert_eq!(decoder.icc_profile().expect("read jpeg icc"), None);
}
#[test]
fn transform_raster_lossy_jpeg_optimization_preserves_icc_by_default() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(1), Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
optimize: OptimizeMode::Lossy,
..TransformOptions::default()
},
))
.expect("lossy jpeg optimize should preserve icc");
let mut decoder =
JpegDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode jpeg");
assert_eq!(decoder.exif_metadata().expect("read jpeg exif"), None);
assert_eq!(
decoder
.icc_profile()
.expect("read jpeg icc")
.expect("retained icc"),
b"demo-icc-profile".to_vec()
);
}
#[test]
fn lossless_jpeg_optimization_rejects_truncated_scan_data() {
let mut bytes = jpeg_artifact_with_metadata(4, 2, Some(1), Some(b"demo-icc-profile")).bytes;
bytes.truncate(bytes.len() - 2);
let error = optimize_jpeg_bytes_losslessly(&bytes, MetadataPolicy::StripAll)
.expect_err("truncated jpeg should be rejected");
assert_eq!(
error,
TransformError::InvalidInput("input is not a valid JPEG bitstream".to_string())
);
}
#[cfg(feature = "webp-lossy")]
#[test]
fn transform_raster_lossy_webp_optimization_rejects_metadata_retention() {
let artifact = jpeg_artifact_with_metadata(4, 2, None, Some(b"demo-icc-profile"));
let error = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Webp),
optimize: OptimizeMode::Lossy,
..TransformOptions::default()
},
))
.expect_err("lossy webp optimize should reject metadata retention");
assert_eq!(
error,
TransformError::CapabilityMissing(
"lossy WebP optimization cannot preserve metadata".to_string()
)
);
}
#[test]
fn transform_raster_keeps_metadata_in_png_output() {
let artifact = jpeg_artifact_with_metadata(4, 2, None, Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Png),
strip_metadata: false,
..TransformOptions::default()
},
))
.expect("keep metadata in png output");
let mut decoder = PngDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode png");
let icc_profile = decoder
.icc_profile()
.expect("read png icc")
.expect("retained png icc");
assert_eq!(icc_profile, b"demo-icc-profile".to_vec());
}
#[test]
fn transform_raster_keeps_metadata_from_webp_input() {
let artifact = webp_artifact_with_metadata(4, 2, None, Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Webp),
strip_metadata: false,
..TransformOptions::default()
},
))
.expect("keep metadata from webp input");
let mut decoder =
WebPDecoder::new(Cursor::new(&result.artifact.bytes)).expect("decode webp");
let icc_profile = decoder
.icc_profile()
.expect("read webp icc")
.expect("retained webp icc");
assert_eq!(icc_profile, b"demo-icc-profile".to_vec());
}
#[test]
fn transform_raster_keep_metadata_succeeds_when_input_has_no_metadata() {
let artifact = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
strip_metadata: false,
..TransformOptions::default()
},
))
.expect("keep metadata should succeed when nothing is present");
assert_eq!(result.artifact.media_type, MediaType::Png);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
}
#[cfg(feature = "avif")]
#[test]
fn transform_raster_rejects_preserved_metadata_for_avif_output() {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(6), Some(b"demo-icc-profile"));
let err = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Avif),
strip_metadata: false,
preserve_exif: true,
..TransformOptions::default()
},
))
.expect_err("avif output should reject preserved exif");
assert_eq!(
err,
TransformError::CapabilityMissing(
"metadata retention is not implemented for avif output".to_string()
)
);
}
#[cfg(feature = "webp-lossy")]
#[test]
fn transform_raster_encodes_lossy_webp_with_quality() {
let artifact = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Webp),
quality: Some(80),
..TransformOptions::default()
},
))
.expect("lossy webp encode should succeed");
assert_eq!(result.artifact.media_type, MediaType::Webp);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
assert!(!result.artifact.bytes.is_empty());
}
#[cfg(feature = "webp-lossy")]
#[test]
fn transform_raster_lossy_webp_smaller_at_lower_quality() {
let artifact = png_artifact(16, 16, Rgba([128, 64, 32, 255]));
let high_q = transform_raster(TransformRequest::new(
artifact.clone(),
TransformOptions {
format: Some(MediaType::Webp),
quality: Some(95),
..TransformOptions::default()
},
))
.expect("high quality webp");
let low_q = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Webp),
quality: Some(10),
..TransformOptions::default()
},
))
.expect("low quality webp");
assert!(
low_q.artifact.bytes.len() <= high_q.artifact.bytes.len(),
"low quality ({}) should be <= high quality ({})",
low_q.artifact.bytes.len(),
high_q.artifact.bytes.len()
);
}
#[cfg(feature = "webp-lossy")]
#[test]
fn transform_raster_lossy_webp_warns_only_for_metadata_that_existed() {
let artifact = webp_artifact_with_metadata(4, 2, None, Some(b"demo-icc-profile"));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Webp),
strip_metadata: false,
quality: Some(80),
..TransformOptions::default()
},
))
.expect("lossy webp quality encode should succeed");
assert!(
result
.warnings
.contains(&TransformWarning::MetadataDropped(MetadataKind::Icc))
);
assert!(
!result
.warnings
.contains(&TransformWarning::MetadataDropped(MetadataKind::Exif))
);
}
#[cfg(feature = "avif")]
#[test]
fn transform_raster_can_convert_png_to_avif() {
let artifact = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Avif),
quality: Some(72),
..TransformOptions::default()
},
))
.expect("avif encode should succeed");
let sniffed = sniff_artifact(RawArtifact::new(result.artifact.bytes.clone(), None))
.expect("sniff avif output");
assert_eq!(result.artifact.media_type, MediaType::Avif);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
assert_eq!(sniffed.media_type, MediaType::Avif);
}
#[cfg(feature = "avif")]
#[test]
fn transform_raster_round_trips_avif_decode() {
let source = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let avif_result = transform_raster(TransformRequest::new(
source,
TransformOptions {
format: Some(MediaType::Avif),
..TransformOptions::default()
},
))
.expect("avif encode should succeed");
let avif_artifact = avif_result.artifact;
assert_eq!(avif_artifact.media_type, MediaType::Avif);
let png_result = transform_raster(TransformRequest::new(
avif_artifact,
TransformOptions {
format: Some(MediaType::Png),
..TransformOptions::default()
},
))
.expect("avif decode should succeed");
assert_eq!(png_result.artifact.media_type, MediaType::Png);
assert_eq!(png_result.artifact.metadata.width, Some(4));
assert_eq!(png_result.artifact.metadata.height, Some(3));
}
#[cfg(feature = "avif")]
#[test]
fn transform_raster_decodes_avif_with_resize() {
let source = png_artifact(8, 6, Rgba([100, 150, 200, 255]));
let avif_result = transform_raster(TransformRequest::new(
source,
TransformOptions {
format: Some(MediaType::Avif),
..TransformOptions::default()
},
))
.expect("avif encode should succeed");
let result = transform_raster(TransformRequest::new(
avif_result.artifact,
TransformOptions {
format: Some(MediaType::Png),
width: Some(4),
height: Some(3),
..TransformOptions::default()
},
))
.expect("avif decode with resize should succeed");
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
}
#[cfg(feature = "avif")]
#[test]
fn transform_raster_rejects_invalid_avif_data() {
let artifact = Artifact::new(
vec![0, 1, 2, 3],
MediaType::Avif,
ArtifactMetadata {
width: Some(1),
height: Some(1),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
);
let err = transform_raster(TransformRequest::new(artifact, TransformOptions::default()))
.expect_err("invalid avif should fail");
assert!(
matches!(err, TransformError::DecodeFailed(_)),
"expected DecodeFailed, got {err:?}"
);
}
#[test]
fn apply_exif_orientation_rotates_dimensions() {
let image =
image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([10, 20, 30, 255])));
let rotated = apply_exif_orientation(image, 6);
assert_eq!(rotated.dimensions(), (2, 4));
}
#[test]
fn input_pixel_limit_accepts_boundary() {
use super::check_input_pixel_limit;
let input = Artifact::new(
vec![],
MediaType::Png,
ArtifactMetadata {
width: Some(10000),
height: Some(10000),
..ArtifactMetadata::default()
},
);
check_input_pixel_limit(&input).unwrap();
}
#[test]
fn input_pixel_limit_rejects_oversized() {
use super::check_input_pixel_limit;
let input = Artifact::new(
vec![],
MediaType::Png,
ArtifactMetadata {
width: Some(10001),
height: Some(10000),
..ArtifactMetadata::default()
},
);
let err = check_input_pixel_limit(&input).unwrap_err();
assert!(matches!(err, TransformError::LimitExceeded(_)));
}
#[test]
fn output_pixel_limit_accepts_boundary() {
use super::check_output_pixel_limit;
let image = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(
8192,
8192,
Rgba([0, 0, 0, 255]),
));
check_output_pixel_limit(&image, Some(8192), Some(8192)).unwrap();
}
#[test]
fn output_pixel_limit_rejects_oversized() {
use super::check_output_pixel_limit;
let image =
image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(1, 1, Rgba([0, 0, 0, 255])));
let err = check_output_pixel_limit(&image, Some(8193), Some(8192)).unwrap_err();
assert!(matches!(err, TransformError::LimitExceeded(_)));
}
#[test]
fn transform_rejects_oversized_output() {
let input = png_artifact(100, 100, Rgba([10, 20, 30, 255]));
let err = transform_raster(TransformRequest::new(
input,
TransformOptions {
width: Some(8193),
height: Some(8192),
..TransformOptions::default()
},
))
.unwrap_err();
assert!(matches!(err, TransformError::LimitExceeded(_)));
assert!(err.to_string().contains("output image"));
}
#[test]
fn keep_metadata_retains_xmp_iptc_for_jpeg_output() {
use super::extract_retained_metadata;
let artifact = jpeg_with_xmp_iptc();
let (retained, warnings) =
extract_retained_metadata(&artifact, MetadataPolicy::KeepAll, false, MediaType::Jpeg)
.expect("should not error");
assert!(
warnings.is_empty(),
"expected no warnings, got: {warnings:?}"
);
let metadata = retained.expect("metadata should be retained");
assert!(metadata.xmp_metadata.is_some(), "XMP should be retained");
assert!(metadata.iptc_metadata.is_some(), "IPTC should be retained");
}
#[test]
fn keep_metadata_drops_iptc_for_png_output_with_warning() {
use super::extract_retained_metadata;
use crate::core::{MetadataKind, TransformWarning};
let artifact = jpeg_with_xmp_iptc();
let (retained, warnings) =
extract_retained_metadata(&artifact, MetadataPolicy::KeepAll, false, MediaType::Png)
.expect("should not error");
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0],
TransformWarning::MetadataDropped(MetadataKind::Iptc)
);
let metadata = retained.expect("metadata should be retained");
assert!(
metadata.xmp_metadata.is_some(),
"XMP should be retained for PNG"
);
assert!(
metadata.iptc_metadata.is_none(),
"IPTC should be dropped for PNG"
);
}
#[test]
fn keep_metadata_drops_xmp_iptc_for_webp_output_with_warnings() {
use super::extract_retained_metadata;
use crate::core::{MetadataKind, TransformWarning};
let artifact = jpeg_with_xmp_iptc();
let (_, warnings) =
extract_retained_metadata(&artifact, MetadataPolicy::KeepAll, false, MediaType::Webp)
.expect("should not error");
assert_eq!(warnings.len(), 2);
assert_eq!(
warnings[0],
TransformWarning::MetadataDropped(MetadataKind::Xmp)
);
assert_eq!(
warnings[1],
TransformWarning::MetadataDropped(MetadataKind::Iptc)
);
}
#[test]
fn keep_metadata_no_warnings_when_no_xmp_iptc() {
use super::extract_retained_metadata;
let artifact = jpeg_artifact_with_metadata(4, 3, Some(6), None);
let (_, warnings) =
extract_retained_metadata(&artifact, MetadataPolicy::KeepAll, false, MediaType::Jpeg)
.expect("should succeed");
assert!(warnings.is_empty());
}
#[test]
fn strip_metadata_produces_no_warnings() {
use super::extract_retained_metadata;
let artifact = jpeg_artifact_with_metadata(4, 3, Some(6), None);
let (retained, warnings) =
extract_retained_metadata(&artifact, MetadataPolicy::StripAll, false, MediaType::Jpeg)
.expect("should succeed");
assert!(retained.is_none());
assert!(warnings.is_empty());
}
#[test]
fn check_deadline_accepts_within_limit() {
use super::check_deadline;
use std::time::Duration;
check_deadline(Duration::from_secs(29), Duration::from_secs(30), "decode").unwrap();
}
#[test]
fn check_deadline_rejects_exceeded() {
use super::check_deadline;
use std::time::Duration;
let err =
check_deadline(Duration::from_secs(31), Duration::from_secs(30), "decode").unwrap_err();
assert!(matches!(err, TransformError::LimitExceeded(_)));
assert!(err.to_string().contains("decode"));
assert!(err.to_string().contains("30s"));
}
#[test]
fn transform_with_deadline_succeeds_for_small_image() {
use std::time::Duration;
let input = png_artifact(2, 2, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
input,
TransformOptions {
format: Some(MediaType::Jpeg),
deadline: Some(Duration::from_secs(30)),
..TransformOptions::default()
},
))
.unwrap();
assert_eq!(result.artifact.media_type, MediaType::Jpeg);
}
#[test]
fn inject_jpeg_xmp_inserts_app1_segment() {
use super::inject_jpeg_xmp;
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([10, 20, 30]));
let mut jpeg_bytes = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_bytes, 80)
.write_image(&image, 2, 2, ColorType::Rgb8.into())
.expect("encode jpeg");
let xmp_payload = b"<x:xmpmeta>hello</x:xmpmeta>";
let result = inject_jpeg_xmp(&jpeg_bytes, xmp_payload).expect("inject XMP");
assert_eq!(&result[..2], &[0xFF, 0xD8]);
assert_eq!(&result[2..4], &[0xFF, 0xE1]);
let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
assert!(result.windows(xmp_ns.len()).any(|w| w == xmp_ns));
image::load_from_memory_with_format(&result, ImageFormat::Jpeg)
.expect("injected JPEG should still decode");
}
#[test]
fn inject_jpeg_iptc_inserts_app13_segment() {
use super::inject_jpeg_iptc;
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([10, 20, 30]));
let mut jpeg_bytes = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_bytes, 80)
.write_image(&image, 2, 2, ColorType::Rgb8.into())
.expect("encode jpeg");
let iptc_payload = b"\x1c\x02\x00\x00\x02OK";
let result = inject_jpeg_iptc(&jpeg_bytes, iptc_payload).expect("inject IPTC");
assert_eq!(&result[..2], &[0xFF, 0xD8]);
assert_eq!(&result[2..4], &[0xFF, 0xED]);
assert!(
result
.windows(b"Photoshop 3.0\0".len())
.any(|w| w == b"Photoshop 3.0\0")
);
assert!(result.windows(b"8BIM".len()).any(|w| w == b"8BIM"));
image::load_from_memory_with_format(&result, ImageFormat::Jpeg)
.expect("injected JPEG should still decode");
}
#[test]
fn inject_png_xmp_inserts_itxt_chunk() {
use super::inject_png_xmp;
let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 255]));
let mut png_bytes = Vec::new();
PngEncoder::new(&mut png_bytes)
.write_image(&image, 2, 2, ColorType::Rgba8.into())
.expect("encode png");
let xmp_payload = b"<x:xmpmeta>hello</x:xmpmeta>";
let result = inject_png_xmp(&png_bytes, xmp_payload).expect("inject XMP into PNG");
assert!(
result
.windows(b"XML:com.adobe.xmp".len())
.any(|w| w == b"XML:com.adobe.xmp")
);
assert!(result.windows(b"iTXt".len()).any(|w| w == b"iTXt"));
image::load_from_memory_with_format(&result, ImageFormat::Png)
.expect("injected PNG should still decode");
}
#[test]
fn inject_jpeg_xmp_rejects_non_jpeg() {
use super::inject_jpeg_xmp;
let result = inject_jpeg_xmp(b"not a jpeg", b"<xmp/>");
assert!(result.is_err());
}
#[test]
fn inject_png_xmp_rejects_non_png() {
use super::inject_png_xmp;
let result = inject_png_xmp(b"not a png", b"<xmp/>");
assert!(result.is_err());
}
#[test]
fn inject_jpeg_xmp_rejects_oversized_payload() {
use super::inject_jpeg_xmp;
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([10, 20, 30]));
let mut jpeg_bytes = Vec::new();
JpegEncoder::new_with_quality(&mut jpeg_bytes, 80)
.write_image(&image, 2, 2, ColorType::Rgb8.into())
.expect("encode jpeg");
let oversized = vec![0u8; 70_000];
let result = inject_jpeg_xmp(&jpeg_bytes, &oversized);
assert!(result.is_err());
}
#[test]
fn transform_raster_round_trips_xmp_in_jpeg() {
use crate::core::{MetadataKind, TransformWarning};
let artifact = jpeg_with_xmp_iptc();
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
strip_metadata: false,
format: Some(MediaType::Jpeg),
..TransformOptions::default()
},
))
.expect("keep-metadata transform");
let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
assert!(
result
.artifact
.bytes
.windows(xmp_ns.len())
.any(|w| w == xmp_ns),
"XMP namespace should be present in output JPEG"
);
assert!(
!result
.warnings
.iter()
.any(|w| matches!(w, TransformWarning::MetadataDropped(MetadataKind::Xmp))),
"should not have XMP dropped warning"
);
}
#[test]
fn transform_raster_round_trips_xmp_in_png() {
use crate::core::{MetadataKind, TransformWarning};
let artifact = jpeg_with_xmp_iptc();
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
strip_metadata: false,
format: Some(MediaType::Png),
..TransformOptions::default()
},
))
.expect("keep-metadata transform to PNG");
assert!(
result
.artifact
.bytes
.windows(b"XML:com.adobe.xmp".len())
.any(|w| w == b"XML:com.adobe.xmp"),
"XMP keyword should be present in output PNG"
);
assert!(
result
.warnings
.iter()
.any(|w| matches!(w, TransformWarning::MetadataDropped(MetadataKind::Iptc))),
"should have IPTC dropped warning for PNG output"
);
}
#[test]
fn transform_raster_can_convert_png_to_bmp() {
let artifact = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Bmp),
..TransformOptions::default()
},
))
.expect("convert png to bmp");
assert_eq!(result.artifact.media_type, MediaType::Bmp);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
assert_eq!(&result.artifact.bytes[0..2], b"BM");
}
#[test]
fn transform_raster_can_convert_bmp_to_png() {
let png = png_artifact(4, 3, Rgba([10, 20, 30, 255]));
let bmp_result = transform_raster(TransformRequest::new(
png,
TransformOptions {
format: Some(MediaType::Bmp),
..TransformOptions::default()
},
))
.expect("create bmp");
let bmp_artifact =
crate::sniff_artifact(crate::RawArtifact::new(bmp_result.artifact.bytes, None))
.expect("sniff bmp");
assert_eq!(bmp_artifact.media_type, MediaType::Bmp);
let result = transform_raster(TransformRequest::new(
bmp_artifact,
TransformOptions {
format: Some(MediaType::Png),
..TransformOptions::default()
},
))
.expect("convert bmp to png");
assert_eq!(result.artifact.media_type, MediaType::Png);
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(3));
}
#[test]
fn transform_raster_can_resize_bmp() {
let png = png_artifact(8, 4, Rgba([10, 20, 30, 255]));
let bmp_result = transform_raster(TransformRequest::new(
png,
TransformOptions {
format: Some(MediaType::Bmp),
..TransformOptions::default()
},
))
.expect("create bmp");
let bmp_artifact =
crate::sniff_artifact(crate::RawArtifact::new(bmp_result.artifact.bytes, None))
.expect("sniff bmp");
let result = transform_raster(TransformRequest::new(
bmp_artifact,
TransformOptions {
width: Some(4),
format: Some(MediaType::Bmp),
..TransformOptions::default()
},
))
.expect("resize bmp");
assert_eq!(result.artifact.metadata.width, Some(4));
assert_eq!(result.artifact.metadata.height, Some(2));
}
#[test]
fn transform_raster_applies_blur() {
let mut image = RgbaImage::from_pixel(8, 8, Rgba([255, 255, 255, 255]));
for y in 0..4 {
for x in 0..4 {
image.put_pixel(x, y, Rgba([0, 0, 0, 255]));
}
}
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&image, 8, 8, ColorType::Rgba8.into())
.expect("encode png");
let artifact = Artifact::new(
bytes,
MediaType::Png,
ArtifactMetadata {
width: Some(8),
height: Some(8),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
);
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
blur: Some(2.0),
..TransformOptions::default()
},
))
.expect("blur transform");
assert_eq!(result.artifact.metadata.width, Some(8));
assert_eq!(result.artifact.metadata.height, Some(8));
let output = image::load_from_memory_with_format(&result.artifact.bytes, ImageFormat::Png)
.expect("decode output");
let edge_pixel = output.get_pixel(4, 4);
assert!(
edge_pixel[0] > 0 && edge_pixel[0] < 255,
"expected blurred edge pixel to be a mid-tone, got r={}",
edge_pixel[0]
);
}
#[test]
fn transform_raster_applies_sharpen() {
let mut image = RgbaImage::from_pixel(8, 8, Rgba([255, 255, 255, 255]));
for y in 0..4 {
for x in 0..4 {
image.put_pixel(x, y, Rgba([0, 0, 0, 255]));
}
}
let blurred = DynamicImage::ImageRgba8(image).blur(2.0);
let pre_dark = blurred.get_pixel(3, 3)[0] as i32;
let pre_light = blurred.get_pixel(4, 3)[0] as i32;
let pre_contrast = (pre_light - pre_dark).abs();
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(blurred.as_bytes(), 8, 8, ColorType::Rgba8.into())
.expect("encode png");
let artifact = Artifact::new(
bytes,
MediaType::Png,
ArtifactMetadata {
width: Some(8),
height: Some(8),
frame_count: 1,
duration: None,
has_alpha: Some(false),
},
);
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
sharpen: Some(5.0),
..TransformOptions::default()
},
))
.expect("sharpen transform");
assert_eq!(result.artifact.metadata.width, Some(8));
assert_eq!(result.artifact.metadata.height, Some(8));
let output = image::load_from_memory_with_format(&result.artifact.bytes, ImageFormat::Png)
.expect("decode output");
let post_dark = output.get_pixel(3, 3)[0] as i32;
let post_light = output.get_pixel(4, 3)[0] as i32;
let post_contrast = (post_light - post_dark).abs();
assert!(
post_contrast > pre_contrast,
"expected sharpening to increase edge contrast: pre={pre_contrast}, post={post_contrast}"
);
}
#[test]
fn transform_raster_applies_watermark() {
let main = png_artifact(10, 10, Rgba([255, 255, 255, 255]));
let wm = png_artifact(3, 3, Rgba([0, 0, 0, 128]));
let mut request = TransformRequest::new(main, TransformOptions::default());
request.watermark = Some(WatermarkInput {
image: wm,
position: Position::BottomRight,
opacity: 100,
margin: 0,
});
let result = transform_raster(request).expect("watermark transform");
assert_eq!(result.artifact.metadata.width, Some(10));
assert_eq!(result.artifact.metadata.height, Some(10));
let output_image =
image::load_from_memory_with_format(&result.artifact.bytes, ImageFormat::Png)
.expect("decode output");
let pixel = output_image.get_pixel(9, 9);
assert!(
pixel[0] < 255,
"expected watermark to darken the pixel, got r={}",
pixel[0]
);
}
#[test]
fn transform_raster_rejects_oversized_watermark() {
let main = png_artifact(4, 4, Rgba([255, 255, 255, 255]));
let wm = png_artifact(5, 5, Rgba([0, 0, 0, 128]));
let mut request = TransformRequest::new(main, TransformOptions::default());
request.watermark = Some(WatermarkInput {
image: wm,
position: Position::Center,
opacity: 50,
margin: 0,
});
let err = transform_raster(request).expect_err("oversized watermark should fail");
assert_eq!(
err,
TransformError::InvalidOptions(
"watermark image is too large for the output dimensions".to_string()
)
);
}
#[test]
fn watermark_full_width_at_top_with_margin_succeeds() {
let main = png_artifact(10, 10, Rgba([255, 255, 255, 255]));
let wm = png_artifact(10, 3, Rgba([0, 0, 0, 128]));
let mut request = TransformRequest::new(main, TransformOptions::default());
request.watermark = Some(WatermarkInput {
image: wm,
position: Position::Top,
opacity: 50,
margin: 2,
});
let result = transform_raster(request).expect("full-width watermark at Top should succeed");
assert_eq!(result.artifact.metadata.width, Some(10));
}
#[test]
fn watermark_full_height_at_left_with_margin_succeeds() {
let main = png_artifact(10, 10, Rgba([255, 255, 255, 255]));
let wm = png_artifact(3, 10, Rgba([0, 0, 0, 128]));
let mut request = TransformRequest::new(main, TransformOptions::default());
request.watermark = Some(WatermarkInput {
image: wm,
position: Position::Left,
opacity: 50,
margin: 2,
});
let result =
transform_raster(request).expect("full-height watermark at Left should succeed");
assert_eq!(result.artifact.metadata.height, Some(10));
}
#[test]
fn watermark_pixel_limit_enforced() {
let main = png_artifact(4, 4, Rgba([255, 255, 255, 255]));
let mut wm = png_artifact(2, 2, Rgba([0, 0, 0, 128]));
wm.metadata.width = Some(100_000);
wm.metadata.height = Some(100_000);
let mut request = TransformRequest::new(main, TransformOptions::default());
request.watermark = Some(WatermarkInput {
image: wm,
position: Position::Center,
opacity: 50,
margin: 0,
});
let err = transform_raster(request).expect_err("huge watermark should be rejected");
assert!(
matches!(err, TransformError::InvalidOptions(ref msg) if msg.contains("too large"))
|| matches!(err, TransformError::LimitExceeded(ref msg) if msg.contains("pixels")),
"expected InvalidOptions or LimitExceeded, got: {err}"
);
}
#[test]
fn transform_raster_applies_crop() {
use crate::core::CropRegion;
let artifact = png_artifact(4, 4, Rgba([10, 20, 30, 255]));
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
crop: Some(CropRegion {
x: 1,
y: 1,
width: 2,
height: 2,
}),
..TransformOptions::default()
},
))
.expect("crop should succeed");
assert_eq!(result.artifact.metadata.width, Some(2));
assert_eq!(result.artifact.metadata.height, Some(2));
}
#[test]
fn transform_raster_rejects_crop_exceeding_bounds() {
use crate::core::CropRegion;
let artifact = png_artifact(4, 4, Rgba([10, 20, 30, 255]));
let err = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
crop: Some(CropRegion {
x: 0,
y: 0,
width: 10,
height: 10,
}),
..TransformOptions::default()
},
))
.expect_err("crop exceeding bounds should fail");
assert!(
matches!(err, TransformError::InvalidOptions(ref msg) if msg.contains("exceeds image bounds")),
"unexpected error: {err}"
);
}
#[test]
fn apply_exif_orientation_1_identity() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 1);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_2_fliph() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 2);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_3_rotate180() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 3);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_4_flipv() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 4);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_5_transpose() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 5);
assert_eq!(result.dimensions(), (2, 4));
}
#[test]
fn apply_exif_orientation_6_rotate90() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 6);
assert_eq!(result.dimensions(), (2, 4));
}
#[test]
fn apply_exif_orientation_7_transverse() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 7);
assert_eq!(result.dimensions(), (2, 4));
}
#[test]
fn apply_exif_orientation_8_rotate270() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 8);
assert_eq!(result.dimensions(), (2, 4));
}
#[test]
fn apply_exif_orientation_0_passthrough() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 0);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_9_passthrough() {
let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 2, Rgba([1, 2, 3, 255])));
let result = apply_exif_orientation(image, 9);
assert_eq!(result.dimensions(), (4, 2));
}
#[test]
fn apply_exif_orientation_2_pixel_placement() {
let mut img = RgbaImage::from_pixel(4, 2, Rgba([0, 0, 0, 255]));
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let result = apply_exif_orientation(DynamicImage::ImageRgba8(img), 2);
assert_eq!(*result.to_rgba8().get_pixel(3, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn apply_exif_orientation_3_pixel_placement() {
let mut img = RgbaImage::from_pixel(4, 2, Rgba([0, 0, 0, 255]));
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let result = apply_exif_orientation(DynamicImage::ImageRgba8(img), 3);
assert_eq!(*result.to_rgba8().get_pixel(3, 1), Rgba([255, 0, 0, 255]));
}
#[test]
fn apply_exif_orientation_4_pixel_placement() {
let mut img = RgbaImage::from_pixel(4, 2, Rgba([0, 0, 0, 255]));
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let result = apply_exif_orientation(DynamicImage::ImageRgba8(img), 4);
assert_eq!(*result.to_rgba8().get_pixel(0, 1), Rgba([255, 0, 0, 255]));
}
#[test]
fn apply_exif_orientation_6_pixel_placement() {
let mut img = RgbaImage::from_pixel(4, 2, Rgba([0, 0, 0, 255]));
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let result = apply_exif_orientation(DynamicImage::ImageRgba8(img), 6);
let rgba = result.to_rgba8();
assert_eq!(rgba.dimensions(), (2, 4));
assert_eq!(*rgba.get_pixel(1, 0), Rgba([255, 0, 0, 255]));
}
#[test]
fn apply_exif_orientation_8_pixel_placement() {
let mut img = RgbaImage::from_pixel(4, 2, Rgba([0, 0, 0, 255]));
img.put_pixel(0, 0, Rgba([255, 0, 0, 255]));
let result = apply_exif_orientation(DynamicImage::ImageRgba8(img), 8);
let rgba = result.to_rgba8();
assert_eq!(rgba.dimensions(), (2, 4));
assert_eq!(*rgba.get_pixel(0, 3), Rgba([255, 0, 0, 255]));
}
#[test]
fn transform_raster_auto_orients_all_jpeg_orientations() {
for orientation in 1..=8u16 {
let artifact = jpeg_artifact_with_metadata(4, 2, Some(orientation), None);
let result = transform_raster(TransformRequest::new(
artifact,
TransformOptions {
format: Some(MediaType::Jpeg),
..TransformOptions::default()
},
))
.unwrap_or_else(|e| panic!("orientation {orientation} should succeed: {e}"));
let (expected_w, expected_h) = if orientation >= 5 { (2, 4) } else { (4, 2) };
assert_eq!(
result.artifact.metadata.width,
Some(expected_w),
"orientation {orientation}: expected width {expected_w}"
);
assert_eq!(
result.artifact.metadata.height,
Some(expected_h),
"orientation {orientation}: expected height {expected_h}"
);
}
}
}