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>,
}
impl ArtifactMetadata {
pub fn dimensions(&self) -> Option<Dimensions> {
match (self.width, self.height) {
(Some(w), Some(h)) => Some(Dimensions::new(w, h)),
_ => None,
}
}
}
impl Default for ArtifactMetadata {
fn default() -> Self {
Self {
width: None,
height: None,
frame_count: 1,
duration: None,
has_alpha: 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),
}
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"
),
}
}
}
#[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),
}
}
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),
})
}
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),
})
}
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),
})
}
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,
})
}
fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
let mut offset = 2;
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 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),
});
}
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,
})
}
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),
})
}
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),
})
}
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(),
})
}
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:?}"
);
}
}