use std::error::Error;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
pub const MAX_DECODED_PIXELS: u64 = 100_000_000;
pub const MAX_WATERMARK_PIXELS: u64 = 4_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[must_use]
pub struct Dimensions {
pub width: u32,
pub height: u32,
}
impl Dimensions {
pub const fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
#[must_use]
pub const fn pixel_count(self) -> u64 {
self.width as u64 * self.height as u64
}
}
impl fmt::Display for Dimensions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{}", self.width, self.height)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawArtifact {
pub bytes: Vec<u8>,
pub declared_media_type: Option<MediaType>,
}
impl RawArtifact {
pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
Self {
bytes,
declared_media_type,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct Artifact {
pub bytes: Vec<u8>,
pub media_type: MediaType,
pub metadata: ArtifactMetadata,
}
impl Artifact {
pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
Self {
bytes,
media_type,
metadata,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactMetadata {
pub width: Option<u32>,
pub height: Option<u32>,
pub frame_count: u32,
pub duration: Option<Duration>,
pub has_alpha: Option<bool>,
pub orientation: Option<u16>,
}
impl ArtifactMetadata {
pub fn dimensions(&self) -> Option<Dimensions> {
match (self.width, self.height) {
(Some(w), Some(h)) => Some(Dimensions::new(w, h)),
_ => None,
}
}
pub fn oriented_dimensions(&self) -> Option<Dimensions> {
let dimensions = self.dimensions()?;
Some(if orientation_transposes(self.orientation) {
Dimensions::new(dimensions.height, dimensions.width)
} else {
dimensions
})
}
}
pub(crate) const fn orientation_transposes(orientation: Option<u16>) -> bool {
matches!(orientation, Some(5..=8))
}
impl Default for ArtifactMetadata {
fn default() -> Self {
Self {
width: None,
height: None,
frame_count: 1,
duration: None,
has_alpha: None,
orientation: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaType {
Jpeg,
Png,
Webp,
Avif,
Svg,
Bmp,
Tiff,
Gif,
}
impl MediaType {
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::Jpeg => "jpeg",
Self::Png => "png",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Svg => "svg",
Self::Bmp => "bmp",
Self::Tiff => "tiff",
Self::Gif => "gif",
}
}
#[must_use]
pub const fn as_mime(self) -> &'static str {
match self {
Self::Jpeg => "image/jpeg",
Self::Png => "image/png",
Self::Webp => "image/webp",
Self::Avif => "image/avif",
Self::Svg => "image/svg+xml",
Self::Bmp => "image/bmp",
Self::Tiff => "image/tiff",
Self::Gif => "image/gif",
}
}
#[must_use]
pub const fn is_lossy(self) -> bool {
matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
}
#[must_use]
pub const fn supports_optimization(self) -> bool {
matches!(self, Self::Jpeg | Self::Png | Self::Webp | Self::Avif)
}
#[must_use]
pub const fn supports_lossy_optimization(self) -> bool {
matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
}
#[must_use]
pub const fn supports_icc_profile(self) -> bool {
matches!(self, Self::Jpeg | Self::Png | Self::Webp)
}
#[must_use]
pub const fn is_raster(self) -> bool {
!matches!(self, Self::Svg)
}
#[must_use]
pub const fn is_encodable(self) -> bool {
!matches!(self, Self::Gif)
}
#[must_use]
pub const fn default_output(self) -> Self {
if self.is_encodable() { self } else { Self::Png }
}
}
impl fmt::Display for MediaType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_mime())
}
}
impl FromStr for MediaType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"jpeg" | "jpg" => Ok(Self::Jpeg),
"png" => Ok(Self::Png),
"webp" => Ok(Self::Webp),
"avif" => Ok(Self::Avif),
"svg" => Ok(Self::Svg),
"bmp" => Ok(Self::Bmp),
"tiff" | "tif" => Ok(Self::Tiff),
"gif" => Ok(Self::Gif),
_ => Err(format!("unsupported media type `{value}`")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WatermarkInput {
pub image: Artifact,
pub position: Position,
pub opacity: u8,
pub margin: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TransformRequest {
pub input: Artifact,
pub options: TransformOptions,
pub watermark: Option<WatermarkInput>,
}
impl TransformRequest {
pub fn new(input: Artifact, options: TransformOptions) -> Self {
Self {
input,
options,
watermark: None,
}
}
pub fn with_watermark(
input: Artifact,
options: TransformOptions,
watermark: WatermarkInput,
) -> Self {
Self {
input,
options,
watermark: Some(watermark),
}
}
pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
let options = self.options.normalize(self.input.media_type)?;
if let Some(ref wm) = self.watermark {
validate_watermark(wm)?;
}
Ok(NormalizedTransformRequest {
input: self.input,
options,
watermark: self.watermark,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedTransformRequest {
pub input: Artifact,
pub options: NormalizedTransformOptions,
pub watermark: Option<WatermarkInput>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CropRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl FromStr for CropRegion {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 4 {
return Err(format!(
"crop must be x,y,w,h (four comma-separated integers), got '{s}'"
));
}
let x = parts[0]
.parse::<u32>()
.map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
let y = parts[1]
.parse::<u32>()
.map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
let width = parts[2].parse::<u32>().map_err(|_| {
format!(
"crop width must be a non-negative integer, got '{}'",
parts[2]
)
})?;
let height = parts[3].parse::<u32>().map_err(|_| {
format!(
"crop height must be a non-negative integer, got '{}'",
parts[3]
)
})?;
if width == 0 || height == 0 {
return Err("crop width and height must be greater than zero".to_string());
}
Ok(CropRegion {
x,
y,
width,
height,
})
}
}
impl fmt::Display for CropRegion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OptimizeMode {
#[default]
None,
Auto,
Lossless,
Lossy,
}
impl OptimizeMode {
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::None => "none",
Self::Auto => "auto",
Self::Lossless => "lossless",
Self::Lossy => "lossy",
}
}
}
impl fmt::Display for OptimizeMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_name())
}
}
impl FromStr for OptimizeMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"none" => Ok(Self::None),
"auto" => Ok(Self::Auto),
"lossless" => Ok(Self::Lossless),
"lossy" => Ok(Self::Lossy),
_ => Err(format!("unsupported optimize mode `{value}`")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QualityMetric {
Ssim,
Psnr,
}
impl QualityMetric {
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::Ssim => "ssim",
Self::Psnr => "psnr",
}
}
}
impl fmt::Display for QualityMetric {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_name())
}
}
impl FromStr for QualityMetric {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"ssim" => Ok(Self::Ssim),
"psnr" => Ok(Self::Psnr),
_ => Err(format!("unsupported target quality metric `{value}`")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TargetQuality {
pub metric: QualityMetric,
pub value: f32,
}
impl fmt::Display for TargetQuality {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.metric.as_name(), self.value)
}
}
impl FromStr for TargetQuality {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (metric, raw_value) = value.split_once(':').ok_or_else(|| {
"targetQuality must be <metric>:<value>, for example ssim:0.98".to_string()
})?;
let metric = QualityMetric::from_str(&metric.to_ascii_lowercase())?;
let value = raw_value
.parse::<f32>()
.map_err(|_| format!("target quality value must be a number, got `{raw_value}`"))?;
Ok(Self { metric, value })
}
}
pub(crate) fn default_lossy_target_quality(media_type: MediaType) -> Option<TargetQuality> {
let value = match media_type {
MediaType::Jpeg | MediaType::Webp => 0.985,
MediaType::Avif => 0.99,
_ => return None,
};
Some(TargetQuality {
metric: QualityMetric::Ssim,
value,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct TransformOptions {
pub width: Option<u32>,
pub height: Option<u32>,
pub fit: Option<Fit>,
pub position: Option<Position>,
pub format: Option<MediaType>,
pub quality: Option<u8>,
pub optimize: OptimizeMode,
pub target_quality: Option<TargetQuality>,
pub background: Option<Rgba8>,
pub rotate: Rotation,
pub auto_orient: bool,
pub strip_metadata: bool,
pub preserve_exif: bool,
pub blur: Option<f32>,
pub sharpen: Option<f32>,
pub grayscale: bool,
pub without_enlargement: bool,
pub crop: Option<CropRegion>,
pub deadline: Option<Duration>,
}
impl Default for TransformOptions {
fn default() -> Self {
Self {
width: None,
height: None,
fit: None,
position: None,
format: None,
quality: None,
optimize: OptimizeMode::None,
target_quality: None,
background: None,
rotate: Rotation::DEG_0,
auto_orient: true,
strip_metadata: true,
preserve_exif: false,
blur: None,
sharpen: None,
grayscale: false,
without_enlargement: false,
crop: None,
deadline: None,
}
}
}
impl TransformOptions {
pub fn normalize(
self,
input_media_type: MediaType,
) -> Result<NormalizedTransformOptions, TransformError> {
validate_dimension("width", self.width)?;
validate_dimension("height", self.height)?;
validate_quality(self.quality)?;
validate_target_quality(self.target_quality)?;
validate_blur(self.blur)?;
validate_sharpen(self.sharpen)?;
if let Some(crop) = self.crop
&& (crop.width == 0 || crop.height == 0)
{
return Err(TransformError::InvalidOptions(
"crop width and height must be greater than zero".to_string(),
));
}
let has_bounded_resize = self.width.is_some() && self.height.is_some();
if self.fit.is_some() && !has_bounded_resize {
return Err(TransformError::InvalidOptions(
"fit requires both width and height".to_string(),
));
}
if self.position.is_some() && !has_bounded_resize {
return Err(TransformError::InvalidOptions(
"position requires both width and height".to_string(),
));
}
if self.without_enlargement && self.width.is_none() && self.height.is_none() {
return Err(TransformError::InvalidOptions(
"withoutEnlargement requires width or height".to_string(),
));
}
if self.preserve_exif && self.strip_metadata {
return Err(TransformError::InvalidOptions(
"preserveExif requires stripMetadata to be false".to_string(),
));
}
let format = self
.format
.unwrap_or_else(|| input_media_type.default_output());
let optimize = self.optimize;
if optimize != OptimizeMode::None && !format.supports_optimization() {
return Err(TransformError::InvalidOptions(format!(
"optimization is not supported for {} output",
format.as_name()
)));
}
if optimize == OptimizeMode::Lossy && !format.supports_lossy_optimization() {
return Err(TransformError::InvalidOptions(format!(
"lossy optimization requires jpeg, webp, or avif output, got {}",
format.as_name()
)));
}
if self.preserve_exif && format == MediaType::Svg {
return Err(TransformError::InvalidOptions(
"preserveExif is not supported with SVG output".to_string(),
));
}
if self.quality.is_some() && !format.is_lossy() {
return Err(TransformError::InvalidOptions(
"quality requires a lossy output format".to_string(),
));
}
if self.quality.is_some() && optimize == OptimizeMode::Lossless {
return Err(TransformError::InvalidOptions(
"quality cannot be combined with optimize=lossless".to_string(),
));
}
if self.target_quality.is_some()
&& matches!(optimize, OptimizeMode::None | OptimizeMode::Lossless)
{
return Err(TransformError::InvalidOptions(
"targetQuality requires optimize=auto or optimize=lossy".to_string(),
));
}
if self.target_quality.is_some() && !format.supports_lossy_optimization() {
return Err(TransformError::InvalidOptions(
"targetQuality requires jpeg, webp, or avif output".to_string(),
));
}
let fit = if has_bounded_resize {
Some(self.fit.unwrap_or(Fit::Contain))
} else {
None
};
Ok(NormalizedTransformOptions {
width: self.width,
height: self.height,
fit,
position: self.position.unwrap_or(Position::Center),
format,
quality: self.quality,
optimize,
target_quality: self.target_quality,
background: self.background,
rotate: self.rotate,
auto_orient: self.auto_orient,
metadata_policy: normalize_metadata_policy(
self.strip_metadata,
self.preserve_exif,
optimize,
format,
),
blur: self.blur,
sharpen: self.sharpen,
grayscale: self.grayscale,
without_enlargement: self.without_enlargement,
crop: self.crop,
deadline: self.deadline,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedTransformOptions {
pub width: Option<u32>,
pub height: Option<u32>,
pub fit: Option<Fit>,
pub position: Position,
pub format: MediaType,
pub quality: Option<u8>,
pub optimize: OptimizeMode,
pub target_quality: Option<TargetQuality>,
pub background: Option<Rgba8>,
pub rotate: Rotation,
pub auto_orient: bool,
pub metadata_policy: MetadataPolicy,
pub blur: Option<f32>,
pub sharpen: Option<f32>,
pub grayscale: bool,
pub without_enlargement: bool,
pub crop: Option<CropRegion>,
pub deadline: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fit {
Contain,
Cover,
Fill,
Inside,
}
impl Fit {
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::Contain => "contain",
Self::Cover => "cover",
Self::Fill => "fill",
Self::Inside => "inside",
}
}
}
impl FromStr for Fit {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"contain" => Ok(Self::Contain),
"cover" => Ok(Self::Cover),
"fill" => Ok(Self::Fill),
"inside" => Ok(Self::Inside),
_ => Err(format!("unsupported fit mode `{value}`")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
Center,
Top,
Right,
Bottom,
Left,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Position {
#[must_use]
pub const fn as_name(self) -> &'static str {
match self {
Self::Center => "center",
Self::Top => "top",
Self::Right => "right",
Self::Bottom => "bottom",
Self::Left => "left",
Self::TopLeft => "top-left",
Self::TopRight => "top-right",
Self::BottomLeft => "bottom-left",
Self::BottomRight => "bottom-right",
}
}
}
impl FromStr for Position {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"center" => Ok(Self::Center),
"top" => Ok(Self::Top),
"right" => Ok(Self::Right),
"bottom" => Ok(Self::Bottom),
"left" => Ok(Self::Left),
"top-left" => Ok(Self::TopLeft),
"top-right" => Ok(Self::TopRight),
"bottom-left" => Ok(Self::BottomLeft),
"bottom-right" => Ok(Self::BottomRight),
_ => Err(format!("unsupported position `{value}`")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rotation(u16);
impl Rotation {
pub const DEG_0: Self = Self(0);
pub const DEG_90: Self = Self(90);
pub const DEG_180: Self = Self(180);
pub const DEG_270: Self = Self(270);
#[must_use]
pub const fn from_degrees(degrees: i32) -> Self {
let wrapped = degrees % 360;
let normalized = if wrapped < 0 { wrapped + 360 } else { wrapped };
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
Self(normalized as u16)
}
#[must_use]
pub const fn as_degrees(self) -> u16 {
self.0
}
#[must_use]
pub const fn is_identity(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn quarter_turns(self) -> Option<u8> {
if self.0.is_multiple_of(90) {
#[allow(clippy::cast_possible_truncation)]
Some((self.0 / 90) as u8)
} else {
None
}
}
}
impl fmt::Display for Rotation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for Rotation {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.parse::<i32>() {
Ok(degrees) => Ok(Self::from_degrees(degrees)),
Err(_) => Err(format!(
"unsupported rotation `{value}`: expected a whole number of degrees"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgba8 {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba8 {
pub fn from_hex(value: &str) -> Result<Self, String> {
if !value.is_ascii() || (value.len() != 6 && value.len() != 8) {
return Err(format!("unsupported color `{value}`"));
}
let r = u8::from_str_radix(&value[0..2], 16)
.map_err(|_| format!("unsupported color `{value}`"))?;
let g = u8::from_str_radix(&value[2..4], 16)
.map_err(|_| format!("unsupported color `{value}`"))?;
let b = u8::from_str_radix(&value[4..6], 16)
.map_err(|_| format!("unsupported color `{value}`"))?;
let a = if value.len() == 8 {
u8::from_str_radix(&value[6..8], 16)
.map_err(|_| format!("unsupported color `{value}`"))?
} else {
u8::MAX
};
Ok(Self { r, g, b, a })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataPolicy {
StripAll,
KeepAll,
PreserveIcc,
PreserveExif,
}
pub fn resolve_metadata_flags(
strip: Option<bool>,
keep: Option<bool>,
preserve_exif: Option<bool>,
) -> Result<(bool, bool), TransformError> {
let keep = keep.unwrap_or(false);
let preserve_exif = preserve_exif.unwrap_or(false);
if keep && preserve_exif {
return Err(TransformError::InvalidOptions(
"keepMetadata and preserveExif cannot both be true".to_string(),
));
}
let strip_metadata = if keep || preserve_exif {
false
} else {
strip.unwrap_or(true)
};
Ok((strip_metadata, preserve_exif))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransformError {
InvalidInput(String),
InvalidOptions(String),
UnsupportedInputMediaType(String),
UnsupportedOutputMediaType(MediaType),
DecodeFailed(String),
EncodeFailed(String),
CapabilityMissing(String),
LimitExceeded(String),
}
impl fmt::Display for TransformError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
Self::UnsupportedInputMediaType(reason) => {
write!(f, "unsupported input media type: {reason}")
}
Self::UnsupportedOutputMediaType(media_type) => match media_type {
MediaType::Svg => write!(
f,
"svg output requires an svg input; choose a raster output format such as png, jpeg, webp, or avif"
),
MediaType::Gif => write!(
f,
"gif is an input-only format; choose an output format such as png, jpeg, webp, or avif"
),
other => write!(f, "unsupported output media type: {other}"),
},
Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
}
}
}
impl Error for TransformError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MetadataKind {
Xmp,
Iptc,
Exif,
Icc,
}
impl fmt::Display for MetadataKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Xmp => f.write_str("XMP"),
Self::Iptc => f.write_str("IPTC"),
Self::Exif => f.write_str("EXIF"),
Self::Icc => f.write_str("ICC profile"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TransformWarning {
MetadataDropped(MetadataKind),
OrientationDropped {
orientation: u16,
},
}
impl fmt::Display for TransformWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MetadataDropped(kind) => write!(
f,
"{kind} metadata was present in the input but could not be preserved by the output encoder"
),
Self::OrientationDropped { orientation } => write!(
f,
"the input carries EXIF orientation {orientation}; with autoOrient off and the metadata stripped the output records it neither in its pixels nor in its metadata, so it displays rotated. Keep the metadata to preserve the tag, or leave autoOrient on to apply it to the pixels"
),
}
}
}
#[derive(Debug)]
#[must_use]
pub struct TransformResult {
pub artifact: Artifact,
pub warnings: Vec<TransformWarning>,
}
#[must_use = "this function returns the detected artifact without side effects"]
pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
let (media_type, metadata) = detect_artifact(&input.bytes)?;
if let Some(declared_media_type) = input.declared_media_type
&& declared_media_type != media_type
{
return Err(TransformError::InvalidInput(
"declared media type does not match detected media type".to_string(),
));
}
Ok(Artifact::new(input.bytes, media_type, metadata))
}
fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
if matches!(value, Some(0)) {
return Err(TransformError::InvalidOptions(format!(
"{name} must be greater than zero"
)));
}
Ok(())
}
fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
if matches!(value, Some(0) | Some(101..=u8::MAX)) {
return Err(TransformError::InvalidOptions(
"quality must be between 1 and 100".to_string(),
));
}
Ok(())
}
fn validate_target_quality(value: Option<TargetQuality>) -> Result<(), TransformError> {
let Some(value) = value else {
return Ok(());
};
if !value.value.is_finite() {
return Err(TransformError::InvalidOptions(
"targetQuality must be finite".to_string(),
));
}
match value.metric {
QualityMetric::Ssim if !(0.0..=1.0).contains(&value.value) || value.value == 0.0 => {
Err(TransformError::InvalidOptions(
"ssim targetQuality must be greater than 0.0 and at most 1.0".to_string(),
))
}
QualityMetric::Psnr if value.value <= 0.0 => Err(TransformError::InvalidOptions(
"psnr targetQuality must be greater than 0".to_string(),
)),
_ => Ok(()),
}
}
fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
if let Some(sigma) = value
&& !(0.1..=100.0).contains(&sigma)
{
return Err(TransformError::InvalidOptions(
"blur sigma must be between 0.1 and 100.0".to_string(),
));
}
Ok(())
}
fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
if let Some(sigma) = value
&& !(0.1..=100.0).contains(&sigma)
{
return Err(TransformError::InvalidOptions(
"sharpen sigma must be between 0.1 and 100.0".to_string(),
));
}
Ok(())
}
fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
if wm.opacity == 0 || wm.opacity > 100 {
return Err(TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string(),
));
}
if !wm.image.media_type.is_raster() {
return Err(TransformError::InvalidOptions(
"watermark image must be a raster format".to_string(),
));
}
Ok(())
}
fn normalize_metadata_policy(
strip_metadata: bool,
preserve_exif: bool,
optimize: OptimizeMode,
format: MediaType,
) -> MetadataPolicy {
if preserve_exif {
MetadataPolicy::PreserveExif
} else if strip_metadata && optimize == OptimizeMode::Lossy && format.supports_icc_profile() {
MetadataPolicy::PreserveIcc
} else if strip_metadata {
MetadataPolicy::StripAll
} else {
MetadataPolicy::KeepAll
}
}
fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
if is_png(bytes) {
return Ok((MediaType::Png, sniff_png(bytes)?));
}
if is_jpeg(bytes) {
return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
}
if is_webp(bytes) {
return Ok((MediaType::Webp, sniff_webp(bytes)?));
}
if is_avif(bytes) {
return Ok((MediaType::Avif, sniff_avif(bytes)?));
}
if is_bmp(bytes) {
return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
}
if is_tiff(bytes) {
return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
}
if is_gif(bytes) {
return Ok((MediaType::Gif, sniff_gif(bytes)?));
}
if is_svg(bytes) {
return Ok((MediaType::Svg, sniff_svg(bytes)));
}
let preview_len = bytes.len().min(16);
let hex_preview: String = bytes[..preview_len]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ");
Err(TransformError::UnsupportedInputMediaType(format!(
"unknown file signature ({} bytes, header: [{hex_preview}])",
bytes.len()
)))
}
fn is_png(bytes: &[u8]) -> bool {
bytes.starts_with(b"\x89PNG\r\n\x1a\n")
}
fn is_jpeg(bytes: &[u8]) -> bool {
bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
}
fn is_webp(bytes: &[u8]) -> bool {
bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
}
fn is_avif(bytes: &[u8]) -> bool {
bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
}
fn is_svg(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return false,
};
let mut remaining = text.trim_start();
remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
remaining = remaining.trim_start();
if let Some(rest) = remaining.strip_prefix("<?xml") {
if let Some(end) = rest.find("?>") {
remaining = rest[end + 2..].trim_start();
} else {
return false;
}
}
if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
if let Some(end) = rest.find('>') {
remaining = rest[end + 1..].trim_start();
} else {
return false;
}
}
while let Some(rest) = remaining.strip_prefix("<!--") {
if let Some(end) = rest.find("-->") {
remaining = rest[end + 3..].trim_start();
} else {
return false;
}
}
remaining.starts_with("<svg")
&& remaining
.as_bytes()
.get(4)
.is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
}
fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
ArtifactMetadata {
width: None,
height: None,
frame_count: 1,
duration: None,
has_alpha: Some(true),
orientation: None,
}
}
fn is_bmp(bytes: &[u8]) -> bool {
bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
}
fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 30 {
return Err(TransformError::DecodeFailed(
"bmp file is too short".to_string(),
));
}
let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
let height = raw_height.unsigned_abs();
let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
let has_alpha = bits_per_pixel == 32;
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(has_alpha),
orientation: None,
})
}
fn is_tiff(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
|| (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
}
fn is_gif(bytes: &[u8]) -> bool {
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
fn sniff_gif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 13 {
return Err(TransformError::DecodeFailed(
"gif file is too short".to_string(),
));
}
let width = u32::from(read_u16_le(&bytes[6..8])?);
let height = u32::from(read_u16_le(&bytes[8..10])?);
let packed = bytes[10];
let mut offset = 13usize;
if packed & 0b1000_0000 != 0 {
let entries = 1usize << ((packed & 0b0000_0111) + 1);
offset = offset.saturating_add(entries * 3);
}
let mut frame_count = 0u32;
let mut has_alpha = false;
while offset < bytes.len() {
match bytes[offset] {
0x3B => break,
0x21 => {
if offset + 1 >= bytes.len() {
break;
}
let label = bytes[offset + 1];
let mut cursor = offset + 2;
if label == 0xF9
&& cursor + 2 < bytes.len()
&& bytes[cursor] >= 1
&& bytes[cursor + 1] & 0b0000_0001 != 0
{
has_alpha = true;
}
cursor = skip_gif_sub_blocks(bytes, cursor)?;
offset = cursor;
}
0x2C => {
frame_count = frame_count.saturating_add(1);
if offset + 10 > bytes.len() {
break;
}
let local_packed = bytes[offset + 9];
let mut cursor = offset + 10;
if local_packed & 0b1000_0000 != 0 {
let entries = 1usize << ((local_packed & 0b0000_0111) + 1);
cursor = cursor.saturating_add(entries * 3);
}
cursor = cursor.saturating_add(1);
cursor = skip_gif_sub_blocks(bytes, cursor)?;
offset = cursor;
}
other => {
return Err(TransformError::DecodeFailed(format!(
"gif file has an unknown block introducer 0x{other:02x}"
)));
}
}
}
if frame_count == 0 {
return Err(TransformError::DecodeFailed(
"gif file contains no image data".to_string(),
));
}
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count,
duration: None,
has_alpha: Some(has_alpha),
orientation: None,
})
}
fn skip_gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Result<usize, TransformError> {
loop {
if offset >= bytes.len() {
return Err(TransformError::DecodeFailed(
"gif file ends inside a data block".to_string(),
));
}
let len = bytes[offset] as usize;
offset += 1;
if len == 0 {
return Ok(offset);
}
offset = offset.saturating_add(len);
}
}
fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
let cursor = std::io::Cursor::new(bytes);
let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
.map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
let (width, height) = image::ImageDecoder::dimensions(&decoder);
let color = image::ImageDecoder::color_type(&decoder);
let has_alpha = matches!(
color,
image::ColorType::La8
| image::ColorType::Rgba8
| image::ColorType::La16
| image::ColorType::Rgba16
| image::ColorType::Rgba32F
);
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(has_alpha),
orientation: None,
})
}
fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 29 {
return Err(TransformError::DecodeFailed(
"png file is too short".to_string(),
));
}
if &bytes[12..16] != b"IHDR" {
return Err(TransformError::DecodeFailed(
"png file is missing an IHDR chunk".to_string(),
));
}
let width = read_u32_be(&bytes[16..20])?;
let height = read_u32_be(&bytes[20..24])?;
let color_type = bytes[25];
let has_alpha = match color_type {
4 | 6 => Some(true),
0 | 2 | 3 => Some(false),
_ => None,
};
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha,
orientation: None,
})
}
pub(crate) fn jpeg_exif_orientation(bytes: &[u8]) -> Option<u16> {
exif_orientation_from_payload(jpeg_exif_payload(bytes)?)
}
fn exif_orientation_from_payload(payload: &[u8]) -> Option<u16> {
let exif = exif::Reader::new().read_raw(payload.to_vec()).ok()?;
let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
match &field.value {
exif::Value::Short(values) => values.first().copied(),
exif::Value::Long(values) => values.first().and_then(|value| u16::try_from(*value).ok()),
_ => None,
}
}
fn jpeg_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
const EXIF_PREFIX: &[u8] = b"Exif\0\0";
const APP1: u8 = 0xE1;
let mut offset = 2;
while offset + 1 < bytes.len() {
if bytes[offset] != 0xFF {
return None;
}
while offset < bytes.len() && bytes[offset] == 0xFF {
offset += 1;
}
let marker = *bytes.get(offset)?;
offset += 1;
if marker == 0xD9 || marker == 0xDA {
return None;
}
if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
continue;
}
let length = read_u16_be(bytes.get(offset..offset + 2)?).ok()? as usize;
if length < 2 || offset + length > bytes.len() {
return None;
}
if marker == APP1
&& let Some(payload) = bytes[offset + 2..offset + length].strip_prefix(EXIF_PREFIX)
{
return Some(payload);
}
offset += length;
}
None
}
fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
let mut offset = 2;
let mut exif_payload: Option<&[u8]> = None;
while offset + 1 < bytes.len() {
if bytes[offset] != 0xFF {
return Err(TransformError::DecodeFailed(
"jpeg file has an invalid marker prefix".to_string(),
));
}
while offset < bytes.len() && bytes[offset] == 0xFF {
offset += 1;
}
if offset >= bytes.len() {
break;
}
let marker = bytes[offset];
offset += 1;
if marker == 0xD9 || marker == 0xDA {
break;
}
if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
continue;
}
if offset + 2 > bytes.len() {
return Err(TransformError::DecodeFailed(
"jpeg segment is truncated".to_string(),
));
}
let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
if segment_length < 2 || offset + segment_length > bytes.len() {
return Err(TransformError::DecodeFailed(
"jpeg segment length is invalid".to_string(),
));
}
if marker == 0xE1
&& exif_payload.is_none()
&& let Some(payload) =
bytes[offset + 2..offset + segment_length].strip_prefix(b"Exif\0\0".as_slice())
{
exif_payload = Some(payload);
}
if is_jpeg_sof_marker(marker) {
if segment_length < 7 {
return Err(TransformError::DecodeFailed(
"jpeg SOF segment is too short".to_string(),
));
}
let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
return Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(false),
orientation: exif_payload.and_then(exif_orientation_from_payload),
});
}
offset += segment_length;
}
Err(TransformError::DecodeFailed(
"jpeg file is missing a SOF segment".to_string(),
))
}
fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
let mut offset = 12;
while offset + 8 <= bytes.len() {
let chunk_tag = &bytes[offset..offset + 4];
let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
let chunk_start = offset + 8;
let chunk_end = chunk_start
.checked_add(chunk_size)
.ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
if chunk_end > bytes.len() {
return Err(TransformError::DecodeFailed(
"webp chunk exceeds file length".to_string(),
));
}
let chunk_data = &bytes[chunk_start..chunk_end];
match chunk_tag {
b"VP8X" => return sniff_webp_vp8x(chunk_data),
b"VP8 " => return sniff_webp_vp8(chunk_data),
b"VP8L" => return sniff_webp_vp8l(chunk_data),
_ => {}
}
offset = chunk_end + (chunk_size % 2);
}
Err(TransformError::DecodeFailed(
"webp file is missing an image chunk".to_string(),
))
}
fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 10 {
return Err(TransformError::DecodeFailed(
"webp VP8X chunk is too short".to_string(),
));
}
let flags = bytes[0];
let width = read_u24_le(&bytes[4..7])? + 1;
let height = read_u24_le(&bytes[7..10])? + 1;
let has_alpha = Some(flags & 0b0001_0000 != 0);
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha,
orientation: None,
})
}
fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 10 {
return Err(TransformError::DecodeFailed(
"webp VP8 chunk is too short".to_string(),
));
}
if bytes[3..6] != [0x9D, 0x01, 0x2A] {
return Err(TransformError::DecodeFailed(
"webp VP8 chunk has an invalid start code".to_string(),
));
}
let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(false),
orientation: None,
})
}
fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 5 {
return Err(TransformError::DecodeFailed(
"webp VP8L chunk is too short".to_string(),
));
}
if bytes[0] != 0x2F {
return Err(TransformError::DecodeFailed(
"webp VP8L chunk has an invalid signature".to_string(),
));
}
let bits = read_u32_le(&bytes[1..5])?;
let width = (bits & 0x3FFF) + 1;
let height = ((bits >> 14) & 0x3FFF) + 1;
let has_alpha = (bits >> 28) & 1 != 0;
Ok(ArtifactMetadata {
width: Some(width),
height: Some(height),
frame_count: 1,
duration: None,
has_alpha: Some(has_alpha),
orientation: None,
})
}
fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 16 {
return Err(TransformError::DecodeFailed(
"avif file is too short".to_string(),
));
}
if !has_avif_brand(&bytes[8..]) {
return Err(TransformError::DecodeFailed(
"avif file is missing a compatible AVIF brand".to_string(),
));
}
let inspection = inspect_avif_container(bytes)?;
Ok(ArtifactMetadata {
width: inspection.dimensions.map(|(width, _)| width),
height: inspection.dimensions.map(|(_, height)| height),
frame_count: 1,
duration: None,
has_alpha: inspection.has_alpha(),
orientation: None,
})
}
fn has_avif_brand(bytes: &[u8]) -> bool {
if bytes.len() < 8 {
return false;
}
if is_avif_brand(&bytes[0..4]) {
return true;
}
let mut offset = 8;
while offset + 4 <= bytes.len() {
if is_avif_brand(&bytes[offset..offset + 4]) {
return true;
}
offset += 4;
}
false
}
fn is_avif_brand(bytes: &[u8]) -> bool {
matches!(bytes, b"avif" | b"avis")
}
const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
#[derive(Debug, Default)]
struct AvifInspection {
dimensions: Option<(u32, u32)>,
saw_structured_meta: bool,
found_alpha_item: bool,
}
impl AvifInspection {
fn has_alpha(&self) -> Option<bool> {
if self.saw_structured_meta {
Some(self.found_alpha_item)
} else {
None
}
}
}
fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
let mut inspection = AvifInspection::default();
inspect_avif_boxes(bytes, &mut inspection)?;
Ok(inspection)
}
fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
let mut offset = 0;
while offset + 8 <= bytes.len() {
let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
match box_type {
b"meta" | b"iref" => {
inspection.saw_structured_meta = true;
if payload.len() < 4 {
return Err(TransformError::DecodeFailed(format!(
"{} box is too short",
String::from_utf8_lossy(box_type)
)));
}
inspect_avif_boxes(&payload[4..], inspection)?;
}
b"iprp" | b"ipco" => {
inspection.saw_structured_meta = true;
inspect_avif_boxes(payload, inspection)?;
}
b"ispe" => {
inspection.saw_structured_meta = true;
if inspection.dimensions.is_none() {
inspection.dimensions = Some(parse_avif_ispe(payload)?);
}
}
b"auxC" => {
inspection.saw_structured_meta = true;
if avif_auxc_declares_alpha(payload)? {
inspection.found_alpha_item = true;
}
}
b"auxl" => {
inspection.saw_structured_meta = true;
inspection.found_alpha_item = true;
}
_ => {}
}
offset = next_offset;
}
if offset != bytes.len() {
return Err(TransformError::DecodeFailed(
"avif box payload has trailing bytes".to_string(),
));
}
Ok(())
}
fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
if offset + 8 > bytes.len() {
return Err(TransformError::DecodeFailed(
"mp4 box header is truncated".to_string(),
));
}
let size = read_u32_be(&bytes[offset..offset + 4])?;
let box_type = bytes[offset + 4..offset + 8]
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
let mut header_len = 8_usize;
let end = match size {
0 => bytes.len(),
1 => {
if offset + 16 > bytes.len() {
return Err(TransformError::DecodeFailed(
"extended mp4 box header is truncated".to_string(),
));
}
header_len = 16;
let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
usize::try_from(extended_size)
.map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
}
_ => size as usize,
};
if end < header_len {
return Err(TransformError::DecodeFailed(
"mp4 box size is smaller than its header".to_string(),
));
}
let box_end = offset
.checked_add(end)
.ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
if box_end > bytes.len() {
return Err(TransformError::DecodeFailed(
"mp4 box exceeds file length".to_string(),
));
}
Ok((box_type, &bytes[offset + header_len..box_end], box_end))
}
fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
if bytes.len() < 12 {
return Err(TransformError::DecodeFailed(
"avif ispe box is too short".to_string(),
));
}
let width = read_u32_be(&bytes[4..8])?;
let height = read_u32_be(&bytes[8..12])?;
Ok((width, height))
}
fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
if bytes.len() < 5 {
return Err(TransformError::DecodeFailed(
"avif auxC box is too short".to_string(),
));
}
let urn = &bytes[4..];
Ok(urn
.strip_suffix(&[0])
.is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
}
fn is_jpeg_sof_marker(marker: u8) -> bool {
matches!(
marker,
0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
)
}
fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
let array: [u8; 2] = bytes
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
Ok(u16::from_be_bytes(array))
}
fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
let array: [u8; 2] = bytes
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
Ok(u16::from_le_bytes(array))
}
fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
if bytes.len() != 3 {
return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
}
Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
}
fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
let array: [u8; 4] = bytes
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
Ok(u32::from_be_bytes(array))
}
fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
let array: [u8; 4] = bytes
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
Ok(u32::from_le_bytes(array))
}
fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
let array: [u8; 8] = bytes
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
Ok(u64::from_be_bytes(array))
}
#[cfg(test)]
mod tests {
use super::{
Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, OptimizeMode, Position,
QualityMetric, RawArtifact, Rgba8, Rotation, TargetQuality, TransformError,
TransformOptions, TransformRequest, sniff_artifact,
};
#[cfg(feature = "avif")]
use image::codecs::avif::AvifEncoder;
use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
fn jpeg_artifact() -> Artifact {
Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
}
fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
bytes.extend_from_slice(&13_u32.to_be_bytes());
bytes.extend_from_slice(b"IHDR");
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes.push(8);
bytes.push(color_type);
bytes.push(0);
bytes.push(0);
bytes.push(0);
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes
}
fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
bytes.extend_from_slice(&[0; 14]);
bytes.extend_from_slice(&[
0xFF,
0xC0,
0x00,
0x11,
0x08,
(height >> 8) as u8,
height as u8,
(width >> 8) as u8,
width as u8,
0x03,
0x01,
0x11,
0x00,
0x02,
0x11,
0x00,
0x03,
0x11,
0x00,
]);
bytes.extend_from_slice(&[0xFF, 0xD9]);
bytes
}
fn gif_bytes(
version: &[u8; 3],
width: u16,
height: u16,
frames: usize,
transparent: bool,
) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"GIF");
bytes.extend_from_slice(version);
bytes.extend_from_slice(&width.to_le_bytes());
bytes.extend_from_slice(&height.to_le_bytes());
bytes.push(0b1000_0000);
bytes.push(0); bytes.push(0); bytes.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF]);
for _ in 0..frames {
if transparent {
bytes.extend_from_slice(&[0x21, 0xF9, 0x04, 0b0000_0001, 0x00, 0x00, 0x00, 0x00]);
}
bytes.push(0x2C);
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&width.to_le_bytes());
bytes.extend_from_slice(&height.to_le_bytes());
bytes.push(0);
bytes.push(0x02);
bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
}
bytes.push(0x3B);
bytes
}
fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
let width_minus_one = width - 1;
let height_minus_one = height - 1;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&30_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10_u32.to_le_bytes());
bytes.push(flags);
bytes.extend_from_slice(&[0, 0, 0]);
bytes.extend_from_slice(&[
(width_minus_one & 0xFF) as u8,
((width_minus_one >> 8) & 0xFF) as u8,
((width_minus_one >> 16) & 0xFF) as u8,
]);
bytes.extend_from_slice(&[
(height_minus_one & 0xFF) as u8,
((height_minus_one >> 8) & 0xFF) as u8,
((height_minus_one >> 16) & 0xFF) as u8,
]);
bytes
}
fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
webp_vp8l_bytes_with_alpha(width, height, false)
}
fn webp_vp8l_bytes_with_alpha(width: u32, height: u32, alpha_is_used: bool) -> Vec<u8> {
let packed = (width - 1) | ((height - 1) << 14) | (u32::from(alpha_is_used) << 28);
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&17_u32.to_le_bytes());
bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8L");
bytes.extend_from_slice(&5_u32.to_le_bytes());
bytes.push(0x2F);
bytes.extend_from_slice(&packed.to_le_bytes());
bytes.push(0);
bytes
}
fn avif_bytes() -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&24_u32.to_be_bytes());
bytes.extend_from_slice(b"ftyp");
bytes.extend_from_slice(b"avif");
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes.extend_from_slice(b"mif1");
bytes.extend_from_slice(b"avif");
bytes
}
#[cfg(feature = "avif")]
fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
let image = RgbaImage::from_pixel(width, height, fill);
let mut bytes = Vec::new();
AvifEncoder::new(&mut bytes)
.write_image(&image, width, height, ColorType::Rgba8.into())
.expect("encode avif");
bytes
}
#[test]
fn default_transform_options_match_documented_defaults() {
let options = TransformOptions::default();
assert_eq!(options.width, None);
assert_eq!(options.height, None);
assert_eq!(options.fit, None);
assert_eq!(options.position, None);
assert_eq!(options.format, None);
assert_eq!(options.quality, None);
assert_eq!(options.rotate, Rotation::DEG_0);
assert!(options.auto_orient);
assert!(options.strip_metadata);
assert!(!options.preserve_exif);
}
#[test]
fn media_type_helpers_report_expected_values() {
assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
assert!(MediaType::Webp.is_lossy());
assert!(!MediaType::Png.is_lossy());
}
#[test]
fn media_type_parsing_accepts_documented_names() {
assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
assert_eq!("gif".parse::<MediaType>(), Ok(MediaType::Gif));
assert!("heic".parse::<MediaType>().is_err());
}
#[test]
fn fit_position_rotation_and_color_parsing_work() {
assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
assert_eq!(
"bottom-right".parse::<Position>(),
Ok(Position::BottomRight)
);
assert_eq!("270".parse::<Rotation>(), Ok(Rotation::DEG_270));
assert_eq!(
Rgba8::from_hex("AABBCCDD"),
Ok(Rgba8 {
r: 0xAA,
g: 0xBB,
b: 0xCC,
a: 0xDD
})
);
assert!(Rgba8::from_hex("AABB").is_err());
assert!(Rgba8::from_hex("\u{00e9}\u{00e9}\u{00e9}").is_err());
assert!(Rgba8::from_hex("\u{1f600}\u{1f600}").is_err());
}
#[test]
fn normalize_defaults_fit_and_position_for_bounded_resize() {
let normalized = TransformOptions {
width: Some(1200),
height: Some(630),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize bounded resize");
assert_eq!(normalized.fit, Some(Fit::Contain));
assert_eq!(normalized.position, Position::Center);
assert_eq!(normalized.format, MediaType::Jpeg);
assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
}
#[test]
fn normalize_uses_requested_fit_and_output_format() {
let normalized = TransformOptions {
width: Some(320),
height: Some(320),
fit: Some(Fit::Cover),
position: Some(Position::BottomRight),
format: Some(MediaType::Webp),
quality: Some(70),
strip_metadata: false,
preserve_exif: true,
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize explicit values");
assert_eq!(normalized.fit, Some(Fit::Cover));
assert_eq!(normalized.position, Position::BottomRight);
assert_eq!(normalized.format, MediaType::Webp);
assert_eq!(normalized.quality, Some(70));
assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
}
#[test]
fn normalize_can_keep_all_metadata() {
let normalized = TransformOptions {
strip_metadata: false,
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize keep metadata");
assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
}
#[test]
fn normalize_lossy_optimize_preserves_icc_by_default() {
let normalized = TransformOptions {
optimize: OptimizeMode::Lossy,
format: Some(MediaType::Jpeg),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize lossy optimize metadata policy");
assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
}
#[test]
fn normalize_lossy_optimize_preserves_icc_for_webp_output() {
let normalized = TransformOptions {
optimize: OptimizeMode::Lossy,
format: Some(MediaType::Webp),
strip_metadata: true,
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize lossy webp metadata policy");
assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
}
#[test]
fn normalize_lossy_optimize_strips_all_for_a_format_without_icc_support() {
let normalized = TransformOptions {
optimize: OptimizeMode::Lossy,
format: Some(MediaType::Avif),
strip_metadata: true,
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize lossy avif metadata policy");
assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
}
#[test]
fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
let normalized = TransformOptions {
width: Some(500),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("normalize unbounded resize");
assert_eq!(normalized.fit, None);
assert_eq!(normalized.position, Position::Center);
}
#[test]
fn normalize_rejects_zero_dimensions() {
let err = TransformOptions {
width: Some(0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("zero width should fail");
assert_eq!(
err,
TransformError::InvalidOptions("width must be greater than zero".to_string())
);
}
#[test]
fn normalize_rejects_fit_without_both_dimensions() {
let err = TransformOptions {
width: Some(300),
fit: Some(Fit::Contain),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("fit without bounded resize should fail");
assert_eq!(
err,
TransformError::InvalidOptions("fit requires both width and height".to_string())
);
}
#[test]
fn normalize_rejects_position_without_both_dimensions() {
let err = TransformOptions {
height: Some(300),
position: Some(Position::Top),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("position without bounded resize should fail");
assert_eq!(
err,
TransformError::InvalidOptions("position requires both width and height".to_string())
);
}
#[test]
fn normalize_rejects_quality_for_lossless_output() {
let err = TransformOptions {
format: Some(MediaType::Png),
quality: Some(80),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("quality for png should fail");
assert_eq!(
err,
TransformError::InvalidOptions("quality requires a lossy output format".to_string())
);
}
#[test]
fn normalize_rejects_zero_quality() {
let err = TransformOptions {
quality: Some(0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("zero quality should fail");
assert_eq!(
err,
TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
);
}
#[test]
fn normalize_rejects_quality_above_one_hundred() {
let err = TransformOptions {
quality: Some(101),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("quality above one hundred should fail");
assert_eq!(
err,
TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
);
}
#[test]
fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
let err = TransformOptions {
preserve_exif: true,
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("preserve_exif should require metadata retention");
assert_eq!(
err,
TransformError::InvalidOptions(
"preserveExif requires stripMetadata to be false".to_string()
)
);
}
#[test]
fn normalize_validates_optimize_and_target_quality_matrix() {
struct Case {
name: &'static str,
input_media_type: MediaType,
options: TransformOptions,
expected_error: Option<&'static str>,
}
let cases = [
Case {
name: "target quality requires optimize auto or lossy",
input_media_type: MediaType::Jpeg,
options: TransformOptions {
format: Some(MediaType::Jpeg),
target_quality: Some(TargetQuality {
metric: QualityMetric::Ssim,
value: 0.98,
}),
..TransformOptions::default()
},
expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
},
Case {
name: "target quality not allowed with lossless optimize",
input_media_type: MediaType::Webp,
options: TransformOptions {
format: Some(MediaType::Webp),
optimize: OptimizeMode::Lossless,
target_quality: Some(TargetQuality {
metric: QualityMetric::Ssim,
value: 0.98,
}),
..TransformOptions::default()
},
expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
},
Case {
name: "target quality requires lossy optimizable output",
input_media_type: MediaType::Png,
options: TransformOptions {
format: Some(MediaType::Png),
optimize: OptimizeMode::Auto,
target_quality: Some(TargetQuality {
metric: QualityMetric::Ssim,
value: 0.98,
}),
..TransformOptions::default()
},
expected_error: Some("targetQuality requires jpeg, webp, or avif output"),
},
Case {
name: "quality cannot combine with lossless optimize",
input_media_type: MediaType::Jpeg,
options: TransformOptions {
format: Some(MediaType::Jpeg),
optimize: OptimizeMode::Lossless,
quality: Some(80),
..TransformOptions::default()
},
expected_error: Some("quality cannot be combined with optimize=lossless"),
},
Case {
name: "lossy optimize requires lossy capable format",
input_media_type: MediaType::Png,
options: TransformOptions {
format: Some(MediaType::Png),
optimize: OptimizeMode::Lossy,
..TransformOptions::default()
},
expected_error: Some(
"lossy optimization requires jpeg, webp, or avif output, got png",
),
},
Case {
name: "optimize unsupported for svg output",
input_media_type: MediaType::Svg,
options: TransformOptions {
format: Some(MediaType::Svg),
optimize: OptimizeMode::Auto,
..TransformOptions::default()
},
expected_error: Some("optimization is not supported for svg output"),
},
Case {
name: "preserve exif unsupported for svg output",
input_media_type: MediaType::Svg,
options: TransformOptions {
format: Some(MediaType::Svg),
preserve_exif: true,
strip_metadata: false,
..TransformOptions::default()
},
expected_error: Some("preserveExif is not supported with SVG output"),
},
Case {
name: "auto optimize accepts lossy target quality",
input_media_type: MediaType::Jpeg,
options: TransformOptions {
format: Some(MediaType::Jpeg),
optimize: OptimizeMode::Auto,
target_quality: Some(TargetQuality {
metric: QualityMetric::Ssim,
value: 0.98,
}),
..TransformOptions::default()
},
expected_error: None,
},
Case {
name: "lossless optimize accepts png without quality",
input_media_type: MediaType::Png,
options: TransformOptions {
format: Some(MediaType::Png),
optimize: OptimizeMode::Lossless,
..TransformOptions::default()
},
expected_error: None,
},
];
for case in cases {
let result = case.options.normalize(case.input_media_type);
match case.expected_error {
Some(message) => {
let error = result.expect_err(case.name);
assert_eq!(
error,
TransformError::InvalidOptions(message.to_string()),
"{}",
case.name
);
}
None => {
result.expect(case.name);
}
}
}
}
#[test]
fn transform_request_normalize_uses_input_media_type_as_default_output() {
let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
let normalized = request.normalize().expect("normalize request");
assert_eq!(normalized.input.media_type, MediaType::Jpeg);
assert_eq!(normalized.options.format, MediaType::Jpeg);
assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
}
#[test]
fn sniff_artifact_detects_png_dimensions_and_alpha() {
let artifact =
sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
assert_eq!(artifact.media_type, MediaType::Png);
assert_eq!(artifact.metadata.width, Some(64));
assert_eq!(artifact.metadata.height, Some(32));
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[test]
fn sniff_artifact_detects_jpeg_dimensions() {
let artifact =
sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
assert_eq!(artifact.media_type, MediaType::Jpeg);
assert_eq!(artifact.metadata.width, Some(320));
assert_eq!(artifact.metadata.height, Some(240));
assert_eq!(artifact.metadata.has_alpha, Some(false));
}
#[test]
fn normalize_defaults_gif_input_to_png_output() {
let options = TransformOptions::default()
.normalize(MediaType::Gif)
.expect("gif input should normalize");
assert_eq!(options.format, MediaType::Png);
}
#[test]
fn normalize_keeps_an_explicit_format_for_gif_input() {
let options = TransformOptions {
format: Some(MediaType::Webp),
..TransformOptions::default()
}
.normalize(MediaType::Gif)
.expect("gif input with an explicit format should normalize");
assert_eq!(options.format, MediaType::Webp);
}
#[test]
fn gif_is_not_encodable() {
assert!(!MediaType::Gif.is_encodable());
for media_type in [
MediaType::Jpeg,
MediaType::Png,
MediaType::Webp,
MediaType::Avif,
MediaType::Svg,
MediaType::Bmp,
MediaType::Tiff,
] {
assert!(
media_type.is_encodable(),
"{} should be encodable",
media_type.as_name()
);
}
}
#[test]
fn sniff_artifact_detects_static_gif87a() {
let artifact = sniff_artifact(RawArtifact::new(
gif_bytes(b"87a", 640, 480, 1, false),
None,
))
.expect("sniff gif87a");
assert_eq!(artifact.media_type, MediaType::Gif);
assert_eq!(artifact.metadata.width, Some(640));
assert_eq!(artifact.metadata.height, Some(480));
assert_eq!(artifact.metadata.frame_count, 1);
assert_eq!(artifact.metadata.has_alpha, Some(false));
}
#[test]
fn sniff_artifact_detects_gif89a_transparency() {
let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 1, true), None))
.expect("sniff transparent gif");
assert_eq!(artifact.media_type, MediaType::Gif);
assert_eq!(
artifact.metadata.has_alpha,
Some(true),
"a Graphic Control Extension with the transparent-color flag means alpha"
);
}
#[test]
fn sniff_artifact_counts_gif_frames() {
let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 8, 8, 5, true), None))
.expect("sniff animated gif");
assert_eq!(artifact.metadata.frame_count, 5);
}
#[test]
fn sniff_gif_rejects_a_header_shorter_than_the_screen_descriptor() {
let err = sniff_artifact(RawArtifact::new(b"GIF89a\x04\x00".to_vec(), None))
.expect_err("a 9-byte gif should be rejected");
assert!(
matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("too short")),
"expected a too-short decode error, got: {err}"
);
}
#[test]
fn sniff_gif_rejects_a_file_truncated_inside_a_data_block() {
let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
bytes.truncate(bytes.len() - 2);
let err = sniff_artifact(RawArtifact::new(bytes, None))
.expect_err("a truncated gif should be rejected");
assert!(
matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("ends inside a data block")),
"expected a truncated-block decode error, got: {err}"
);
}
#[test]
fn sniff_gif_rejects_a_file_with_no_image_data() {
let err = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 0, false), None))
.expect_err("a gif with no frames should be rejected");
assert!(
matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("no image data")),
"expected a no-image-data decode error, got: {err}"
);
}
#[test]
fn sniff_gif_rejects_an_unknown_block_introducer() {
let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
let last = bytes.len() - 1;
bytes[last] = 0x99;
let err = sniff_artifact(RawArtifact::new(bytes, None))
.expect_err("an unknown block introducer should be rejected");
assert!(
matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("unknown block introducer")),
"expected an unknown-introducer decode error, got: {err}"
);
}
#[test]
fn sniff_gif_skips_a_local_color_table() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"GIF89a");
bytes.extend_from_slice(&4u16.to_le_bytes());
bytes.extend_from_slice(&4u16.to_le_bytes());
bytes.extend_from_slice(&[0x00, 0x00, 0x00]); bytes.push(0x2C);
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&4u16.to_le_bytes());
bytes.extend_from_slice(&4u16.to_le_bytes());
bytes.push(0b1000_0001); bytes.extend_from_slice(&[0u8; 12]);
bytes.push(0x02);
bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
bytes.push(0x3B);
let artifact =
sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff gif with local palette");
assert_eq!(artifact.metadata.frame_count, 1);
assert_eq!(artifact.metadata.width, Some(4));
}
#[test]
fn sniff_artifact_detects_webp_vp8x_dimensions() {
let artifact = sniff_artifact(RawArtifact::new(
webp_vp8x_bytes(800, 600, 0b0001_0000),
None,
))
.expect("sniff webp vp8x");
assert_eq!(artifact.media_type, MediaType::Webp);
assert_eq!(artifact.metadata.width, Some(800));
assert_eq!(artifact.metadata.height, Some(600));
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[test]
fn sniff_artifact_detects_webp_vp8l_dimensions() {
let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
.expect("sniff webp vp8l");
assert_eq!(artifact.media_type, MediaType::Webp);
assert_eq!(artifact.metadata.width, Some(123));
assert_eq!(artifact.metadata.height, Some(77));
assert_eq!(artifact.metadata.has_alpha, Some(false));
}
#[test]
fn sniff_artifact_reads_the_webp_vp8l_alpha_bit() {
let artifact = sniff_artifact(RawArtifact::new(
webp_vp8l_bytes_with_alpha(123, 77, true),
None,
))
.expect("sniff webp vp8l");
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[test]
fn sniff_artifact_detects_avif_brand() {
let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
assert_eq!(artifact.media_type, MediaType::Avif);
assert_eq!(artifact.metadata, ArtifactMetadata::default());
}
#[cfg(feature = "avif")]
#[test]
fn sniff_artifact_detects_avif_dimensions_and_alpha() {
let artifact = sniff_artifact(RawArtifact::new(
encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
None,
))
.expect("sniff avif with alpha");
assert_eq!(artifact.media_type, MediaType::Avif);
assert_eq!(artifact.metadata.width, Some(7));
assert_eq!(artifact.metadata.height, Some(5));
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[cfg(feature = "avif")]
#[test]
fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
let artifact = sniff_artifact(RawArtifact::new(
encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
None,
))
.expect("sniff opaque avif");
assert_eq!(artifact.media_type, MediaType::Avif);
assert_eq!(artifact.metadata.width, Some(9));
assert_eq!(artifact.metadata.height, Some(4));
assert_eq!(artifact.metadata.has_alpha, Some(false));
}
#[test]
fn sniff_artifact_rejects_declared_media_type_mismatch() {
let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
.expect_err("declared mismatch should fail");
assert_eq!(
err,
TransformError::InvalidInput(
"declared media type does not match detected media type".to_string()
)
);
}
#[test]
fn sniff_artifact_rejects_unknown_signatures() {
let err =
sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
assert!(
matches!(err, TransformError::UnsupportedInputMediaType(ref msg) if msg.contains("unknown file signature")),
"expected unknown file signature error, got: {err}"
);
let msg = err.to_string();
assert!(msg.contains("4 bytes"), "should include file size: {msg}");
assert!(
msg.contains("01 02 03 04"),
"should include hex preview: {msg}"
);
}
#[test]
fn sniff_artifact_rejects_invalid_png_structure() {
let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
.expect_err("broken png should fail");
assert_eq!(
err,
TransformError::DecodeFailed("png file is too short".to_string())
);
}
#[test]
fn sniff_artifact_detects_bmp_dimensions() {
let mut bmp = Vec::new();
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&0u32.to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes());
bmp.extend_from_slice(&54u32.to_le_bytes());
bmp.extend_from_slice(&40u32.to_le_bytes());
bmp.extend_from_slice(&8u32.to_le_bytes());
bmp.extend_from_slice(&6i32.to_le_bytes());
bmp.extend_from_slice(&1u16.to_le_bytes());
bmp.extend_from_slice(&24u16.to_le_bytes());
bmp.resize(54, 0);
let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
assert_eq!(artifact.media_type, MediaType::Bmp);
assert_eq!(artifact.metadata.width, Some(8));
assert_eq!(artifact.metadata.height, Some(6));
assert_eq!(artifact.metadata.has_alpha, Some(false));
}
#[test]
fn sniff_artifact_detects_bmp_32bit_alpha() {
let mut bmp = Vec::new();
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&0u32.to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes());
bmp.extend_from_slice(&54u32.to_le_bytes());
bmp.extend_from_slice(&40u32.to_le_bytes());
bmp.extend_from_slice(&4u32.to_le_bytes());
bmp.extend_from_slice(&4i32.to_le_bytes());
bmp.extend_from_slice(&1u16.to_le_bytes());
bmp.extend_from_slice(&32u16.to_le_bytes());
bmp.resize(54, 0);
let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
assert_eq!(artifact.media_type, MediaType::Bmp);
assert_eq!(artifact.metadata.has_alpha, Some(true));
}
#[test]
fn sniff_artifact_rejects_too_short_bmp() {
let mut data = b"BM".to_vec();
data.resize(27, 0);
let err =
sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
assert_eq!(
err,
TransformError::DecodeFailed("bmp file is too short".to_string())
);
}
#[test]
fn normalize_rejects_blur_sigma_below_minimum() {
let err = TransformOptions {
blur: Some(0.0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("blur sigma 0.0 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
);
}
#[test]
fn normalize_rejects_blur_sigma_above_maximum() {
let err = TransformOptions {
blur: Some(100.1),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("blur sigma 100.1 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
);
}
#[test]
fn normalize_accepts_blur_sigma_at_boundaries() {
let opts_min = TransformOptions {
blur: Some(0.1),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("blur sigma 0.1 should be accepted");
assert_eq!(opts_min.blur, Some(0.1));
let opts_max = TransformOptions {
blur: Some(100.0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("blur sigma 100.0 should be accepted");
assert_eq!(opts_max.blur, Some(100.0));
}
#[test]
fn normalize_rejects_sharpen_sigma_below_minimum() {
let err = TransformOptions {
sharpen: Some(0.0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("sharpen sigma 0.0 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions(
"sharpen sigma must be between 0.1 and 100.0".to_string()
)
);
}
#[test]
fn normalize_rejects_sharpen_sigma_above_maximum() {
let err = TransformOptions {
sharpen: Some(100.1),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect_err("sharpen sigma 100.1 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions(
"sharpen sigma must be between 0.1 and 100.0".to_string()
)
);
}
#[test]
fn normalize_accepts_sharpen_sigma_at_boundaries() {
let opts_min = TransformOptions {
sharpen: Some(0.1),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("sharpen sigma 0.1 should be accepted");
assert_eq!(opts_min.sharpen, Some(0.1));
let opts_max = TransformOptions {
sharpen: Some(100.0),
..TransformOptions::default()
}
.normalize(MediaType::Jpeg)
.expect("sharpen sigma 100.0 should be accepted");
assert_eq!(opts_max.sharpen, Some(100.0));
}
#[test]
fn validate_watermark_rejects_zero_opacity() {
let wm = super::WatermarkInput {
image: jpeg_artifact(),
position: Position::BottomRight,
opacity: 0,
margin: 10,
};
let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string()
)
);
}
#[test]
fn validate_watermark_rejects_opacity_above_100() {
let wm = super::WatermarkInput {
image: jpeg_artifact(),
position: Position::BottomRight,
opacity: 101,
margin: 10,
};
let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string()
)
);
}
#[test]
fn validate_watermark_rejects_svg_image() {
let wm = super::WatermarkInput {
image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
position: Position::BottomRight,
opacity: 50,
margin: 10,
};
let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
assert_eq!(
err,
TransformError::InvalidOptions("watermark image must be a raster format".to_string())
);
}
#[test]
fn validate_watermark_accepts_valid_input() {
let wm = super::WatermarkInput {
image: jpeg_artifact(),
position: Position::BottomRight,
opacity: 50,
margin: 10,
};
super::validate_watermark(&wm).expect("valid watermark should be accepted");
}
#[test]
fn crop_region_from_str_valid() {
use super::CropRegion;
let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
assert_eq!(crop.x, 10);
assert_eq!(crop.y, 20);
assert_eq!(crop.width, 100);
assert_eq!(crop.height, 200);
}
#[test]
fn crop_region_from_str_zero_width() {
use super::CropRegion;
let err = "10,20,0,200"
.parse::<CropRegion>()
.expect_err("zero width should fail");
assert!(err.contains("greater than zero"), "unexpected error: {err}");
}
#[test]
fn crop_region_from_str_wrong_parts() {
use super::CropRegion;
let err = "10,20,100"
.parse::<CropRegion>()
.expect_err("three parts should fail");
assert!(
err.contains("four comma-separated"),
"unexpected error: {err}"
);
}
#[test]
fn crop_region_display() {
use super::CropRegion;
let crop = CropRegion {
x: 1,
y: 2,
width: 3,
height: 4,
};
assert_eq!(crop.to_string(), "1,2,3,4");
}
#[test]
fn normalize_rejects_zero_dimension_crop() {
use super::{CropRegion, MediaType, TransformOptions};
let opts = TransformOptions {
crop: Some(CropRegion {
x: 0,
y: 0,
width: 0,
height: 100,
}),
..TransformOptions::default()
};
let err = opts
.normalize(MediaType::Jpeg)
.expect_err("zero-width crop should fail");
assert!(
matches!(err, super::TransformError::InvalidOptions(_)),
"unexpected error: {err:?}"
);
}
}