use crate::{
Artifact, CropRegion, MediaType, OptimizeMode, Position, RawArtifact, Rgba8, Rotation,
TargetQuality, TransformError, TransformOptions, TransformRequest, TransformResult,
WatermarkInput, sniff_artifact, transform,
};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WasmTransformOptions {
#[serde(default, deserialize_with = "crate::core::deserialize_width")]
pub width: Option<u32>,
#[serde(default, deserialize_with = "crate::core::deserialize_height")]
pub height: Option<u32>,
pub fit: Option<String>,
pub position: Option<String>,
pub format: Option<String>,
#[serde(default, deserialize_with = "crate::core::deserialize_quality")]
pub quality: Option<u8>,
pub optimize: Option<String>,
pub target_quality: Option<String>,
pub background: Option<String>,
#[serde(
default,
deserialize_with = "crate::core::deserialize_rotation_degrees"
)]
pub rotate: Option<i32>,
pub auto_orient: Option<bool>,
pub strip_metadata: Option<bool>,
pub keep_metadata: Option<bool>,
pub preserve_exif: Option<bool>,
pub crop: Option<String>,
pub blur: Option<f32>,
pub sharpen: Option<f32>,
pub grayscale: Option<bool>,
pub without_enlargement: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WasmCapabilities {
pub svg: bool,
pub webp_lossy: bool,
pub avif: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WasmArtifactInfo {
pub media_type: String,
pub mime_type: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub orientation: Option<u16>,
pub oriented_width: Option<u32>,
pub oriented_height: Option<u32>,
pub frame_count: u32,
pub has_alpha: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WasmInspectResponse {
pub artifact: WasmArtifactInfo,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WasmTransformResponse {
#[serde(skip_serializing)]
pub bytes: Vec<u8>,
pub artifact: WasmArtifactInfo,
pub warnings: Vec<String>,
pub suggested_extension: String,
}
#[cfg(feature = "wasm")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct WasmErrorPayload {
kind: &'static str,
message: String,
}
pub fn browser_capabilities() -> WasmCapabilities {
WasmCapabilities {
svg: cfg!(feature = "svg"),
webp_lossy: cfg!(feature = "webp-lossy"),
avif: cfg!(feature = "avif"),
}
}
pub fn inspect_browser_artifact(
input_bytes: Vec<u8>,
declared_media_type: Option<&str>,
) -> Result<WasmInspectResponse, TransformError> {
let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
Ok(WasmInspectResponse {
artifact: artifact_info(&artifact),
})
}
pub fn transform_browser_artifact(
input_bytes: Vec<u8>,
declared_media_type: Option<&str>,
options: WasmTransformOptions,
) -> Result<WasmTransformResponse, TransformError> {
let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
let options = parse_wasm_options(options)?;
build_transform_response(artifact, options, None)
}
fn sniff_browser_artifact(
input_bytes: Vec<u8>,
declared_media_type: Option<&str>,
) -> Result<Artifact, TransformError> {
let declared_media_type = declared_media_type
.map(|value| parse_media_type(value, "declaredMediaType"))
.transpose()?;
sniff_artifact(RawArtifact::new(input_bytes, declared_media_type))
}
fn parse_wasm_options(options: WasmTransformOptions) -> Result<TransformOptions, TransformError> {
let (strip_metadata, preserve_exif) = crate::core::resolve_metadata_flags(
options.strip_metadata,
options.keep_metadata,
options.preserve_exif,
)?;
let fit = parse_optional_enum(options.fit, "fit")?;
let position = parse_optional_enum(options.position, "position")?;
let format = options
.format
.as_deref()
.map(|value| parse_media_type(value, "format"))
.transpose()?;
let optimize = options
.optimize
.as_deref()
.map(|value| {
OptimizeMode::from_str(value).map_err(|reason| {
TransformError::InvalidOptions(format!("optimize is invalid: {reason}"))
})
})
.transpose()?
.unwrap_or_default();
let target_quality = options
.target_quality
.as_deref()
.map(|value| {
TargetQuality::from_str(value).map_err(|reason| {
TransformError::InvalidOptions(format!("targetQuality is invalid: {reason}"))
})
})
.transpose()?;
let background = options
.background
.as_deref()
.map(|value| {
Rgba8::from_hex(value).map_err(|reason| {
TransformError::InvalidOptions(format!("background is invalid: {reason}"))
})
})
.transpose()?;
let crop = options
.crop
.as_deref()
.map(|v| {
CropRegion::from_str(v).map_err(|reason| {
TransformError::InvalidOptions(format!("crop is invalid: {reason}"))
})
})
.transpose()?;
Ok(TransformOptions {
width: options.width,
height: options.height,
fit,
position,
format,
quality: options.quality,
optimize,
target_quality,
background,
rotate: parse_rotation(options.rotate)?,
auto_orient: options.auto_orient.unwrap_or(true),
strip_metadata,
preserve_exif,
crop,
blur: options.blur,
sharpen: options.sharpen,
grayscale: options.grayscale.unwrap_or(false),
without_enlargement: options.without_enlargement.unwrap_or(false),
deadline: None,
})
}
fn parse_optional_enum<T>(value: Option<String>, field: &str) -> Result<Option<T>, TransformError>
where
T: FromStr<Err = String>,
{
value
.map(|value| {
T::from_str(&value).map_err(|reason| {
TransformError::InvalidOptions(format!("{field} is invalid: {reason}"))
})
})
.transpose()
}
fn parse_media_type(value: &str, field: &str) -> Result<MediaType, TransformError> {
let media_type = MediaType::from_str(value).map_err(|reason| {
TransformError::InvalidOptions(format!("{field} is invalid: {reason}"))
})?;
if media_type.unencodable_reason().is_some() {
return Err(TransformError::UnsupportedOutputMediaType(media_type));
}
Ok(media_type)
}
fn parse_rotation(value: Option<i32>) -> Result<Rotation, TransformError> {
Ok(Rotation::from_degrees(value.unwrap_or(0)))
}
fn dispatch_browser_transform_with_watermark(
artifact: Artifact,
options: TransformOptions,
watermark: Option<WatermarkInput>,
) -> Result<TransformResult, TransformError> {
let mut request = TransformRequest::new(artifact, options);
request.watermark = watermark;
transform(request)
}
fn artifact_info(artifact: &Artifact) -> WasmArtifactInfo {
let oriented = artifact.metadata.oriented_dimensions();
WasmArtifactInfo {
media_type: artifact.media_type.as_name().to_string(),
mime_type: artifact.media_type.as_mime().to_string(),
width: artifact.metadata.width,
height: artifact.metadata.height,
orientation: artifact.metadata.orientation,
oriented_width: oriented.map(|dimensions| dimensions.width),
oriented_height: oriented.map(|dimensions| dimensions.height),
frame_count: artifact.metadata.frame_count,
has_alpha: artifact.metadata.has_alpha,
}
}
fn output_extension(media_type: MediaType) -> &'static str {
match media_type {
MediaType::Jpeg => "jpg",
MediaType::Png => "png",
MediaType::Webp => "webp",
MediaType::Avif => "avif",
MediaType::Svg => "svg",
MediaType::Bmp => "bmp",
MediaType::Tiff => "tiff",
MediaType::Gif => "gif",
}
}
#[cfg(any(feature = "wasm", test))]
fn error_kind(error: &TransformError) -> &'static str {
error.class().camel_case_name()
}
#[cfg(feature = "wasm")]
fn serialize_json<T: Serialize>(value: &T) -> Result<String, JsValue> {
serde_json::to_string(value)
.map_err(|error| JsValue::from_str(&format!("failed to serialize WASM response: {error}")))
}
#[cfg(feature = "wasm")]
fn transform_error_to_js(error: TransformError) -> JsValue {
let payload = WasmErrorPayload {
kind: error_kind(&error),
message: crate::core::single_line(&error.to_string()).into_owned(),
};
serialize_json(&payload)
.map(JsValue::from)
.unwrap_or_else(|_| JsValue::from_str(&format!("{}: {}", payload.kind, payload.message)))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WasmWatermarkOptions {
pub position: Option<String>,
pub opacity: Option<u8>,
pub margin: Option<u32>,
}
fn resolve_wasm_watermark(
watermark_bytes: Vec<u8>,
watermark_options: WasmWatermarkOptions,
) -> Result<WatermarkInput, TransformError> {
let artifact = sniff_artifact(RawArtifact::new(watermark_bytes, None))?;
if !artifact.media_type.is_raster() {
return Err(TransformError::InvalidOptions(
"watermark image must be a raster format, not SVG".to_string(),
));
}
let mut watermark = WatermarkInput::new(artifact);
if let Some(value) = watermark_options.position {
watermark.position = Position::from_str(&value).map_err(|reason| {
TransformError::InvalidOptions(format!("watermark position is invalid: {reason}"))
})?;
}
watermark.opacity = watermark_options.opacity.unwrap_or(watermark.opacity);
crate::core::validate_watermark_opacity(watermark.opacity)
.map_err(|message| TransformError::InvalidOptions(message.to_string()))?;
watermark.margin = watermark_options.margin.unwrap_or(watermark.margin);
Ok(watermark)
}
pub fn transform_browser_artifact_with_watermark(
input_bytes: Vec<u8>,
declared_media_type: Option<&str>,
options: WasmTransformOptions,
watermark_bytes: Vec<u8>,
watermark_options: WasmWatermarkOptions,
) -> Result<WasmTransformResponse, TransformError> {
let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
let options = parse_wasm_options(options)?;
let watermark = resolve_wasm_watermark(watermark_bytes, watermark_options)?;
build_transform_response(artifact, options, Some(watermark))
}
fn build_transform_response(
artifact: Artifact,
options: TransformOptions,
watermark: Option<WatermarkInput>,
) -> Result<WasmTransformResponse, TransformError> {
let output = dispatch_browser_transform_with_watermark(artifact, options, watermark)?;
let TransformResult { artifact, warnings } = output;
let artifact_info = artifact_info(&artifact);
let suggested_extension = output_extension(artifact.media_type).to_string();
Ok(WasmTransformResponse {
bytes: artifact.bytes,
artifact: artifact_info,
warnings: warnings
.into_iter()
.map(|warning| warning.to_string())
.collect(),
suggested_extension,
})
}
#[cfg(feature = "wasm")]
#[wasm_bindgen]
pub struct WasmTransformOutput {
bytes: Vec<u8>,
response_json: String,
}
#[cfg(feature = "wasm")]
#[wasm_bindgen]
impl WasmTransformOutput {
#[wasm_bindgen(getter)]
pub fn bytes(&self) -> Vec<u8> {
self.bytes.clone()
}
#[wasm_bindgen(js_name = responseJson, getter)]
pub fn response_json(&self) -> String {
self.response_json.clone()
}
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = getCapabilitiesJson)]
pub fn get_capabilities_json() -> Result<String, JsValue> {
serialize_json(&browser_capabilities())
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = inspectImageJson)]
pub fn inspect_image_json(
input_bytes: &[u8],
declared_media_type: Option<String>,
) -> Result<String, JsValue> {
let response = inspect_browser_artifact(input_bytes.to_vec(), declared_media_type.as_deref())
.map_err(transform_error_to_js)?;
serialize_json(&response)
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = transformImage)]
pub fn transform_image(
input_bytes: &[u8],
declared_media_type: Option<String>,
options_json: &str,
) -> Result<WasmTransformOutput, JsValue> {
let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
transform_error_to_js(TransformError::InvalidOptions(format!(
"failed to parse transform options: {error}"
)))
})?;
let response = transform_browser_artifact(
input_bytes.to_vec(),
declared_media_type.as_deref(),
options,
)
.map_err(transform_error_to_js)?;
let response_json = serialize_json(&response)?;
Ok(WasmTransformOutput {
bytes: response.bytes,
response_json,
})
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = transformImageWithWatermark)]
pub fn transform_image_with_watermark(
input_bytes: &[u8],
declared_media_type: Option<String>,
options_json: &str,
watermark_bytes: &[u8],
watermark_options_json: &str,
) -> Result<WasmTransformOutput, JsValue> {
let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
transform_error_to_js(TransformError::InvalidOptions(format!(
"failed to parse transform options: {error}"
)))
})?;
let watermark_options = serde_json::from_str::<WasmWatermarkOptions>(watermark_options_json)
.map_err(|error| {
transform_error_to_js(TransformError::InvalidOptions(format!(
"failed to parse watermark options: {error}"
)))
})?;
let response = transform_browser_artifact_with_watermark(
input_bytes.to_vec(),
declared_media_type.as_deref(),
options,
watermark_bytes.to_vec(),
watermark_options,
)
.map_err(transform_error_to_js)?;
let response_json = serialize_json(&response)?;
Ok(WasmTransformOutput {
bytes: response.bytes,
response_json,
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
const WASM_DOCS: &str = include_str!("../../docs/wasm.md");
#[rstest]
#[case(r#"{"quality":256}"#, "quality must be between 1 and 100")]
#[case(r#"{"quality":999999}"#, "quality must be between 1 and 100")]
#[case(
r#"{"width":4294967296}"#,
"width is too large to be a number of pixels"
)]
#[case(
r#"{"height":4294967296}"#,
"height is too large to be a number of pixels"
)]
fn an_out_of_range_option_reports_the_documented_rule(
#[case] options_json: &str,
#[case] expected: &str,
) {
let error = serde_json::from_str::<WasmTransformOptions>(options_json)
.expect_err("the value is out of range")
.to_string();
assert!(error.contains(expected), "{error}");
for rust_type in ["u8", "u32", "i32", "0..="] {
assert!(
!error.contains(rust_type),
"a Rust integer is not the caller's vocabulary: {error}"
);
}
}
#[test]
fn a_quality_the_field_can_hold_is_judged_by_the_transform() {
let options = serde_json::from_str::<WasmTransformOptions>(r#"{"quality":101}"#)
.expect("101 fits the field");
assert_eq!(options.quality, Some(101));
let error = transform_browser_artifact(parity_source(), None, options)
.expect_err("101 is not a quality");
assert_eq!(
error.to_string(),
"invalid transform options: quality must be between 1 and 100"
);
}
#[test]
fn a_rotation_past_a_full_turn_is_taken_however_large_it_is() {
let options = serde_json::from_str::<WasmTransformOptions>(r#"{"rotate":9999999999}"#)
.expect("an angle past a full turn is an angle");
assert_eq!(options.rotate, Some((9_999_999_999_i64 % 360) as i32));
assert_eq!(options.rotate, Some(279));
}
fn parity_source() -> Vec<u8> {
use image::codecs::png::PngEncoder;
use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
let image = RgbaImage::from_fn(64, 48, |x, y| {
Rgba([
u8::try_from(x * 4).unwrap_or(255),
u8::try_from(y * 5).unwrap_or(255),
128,
u8::try_from(200 + (x % 55)).unwrap_or(255),
])
});
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&image, 64, 48, ColorType::Rgba8.into())
.expect("encode png");
bytes
}
fn parity_base() -> (TransformOptions, WasmTransformOptions) {
(
TransformOptions {
width: Some(40),
height: Some(30),
fit: Some(crate::Fit::Cover),
position: Some(Position::Top),
format: Some(MediaType::Jpeg),
quality: Some(70),
optimize: OptimizeMode::None,
target_quality: None,
background: Some(Rgba8 {
r: 1,
g: 2,
b: 3,
a: 255,
}),
rotate: Rotation::DEG_90,
auto_orient: true,
strip_metadata: true,
preserve_exif: false,
crop: Some(CropRegion {
x: 1,
y: 2,
width: 30,
height: 40,
}),
blur: Some(1.0),
sharpen: Some(2.0),
grayscale: false,
without_enlargement: false,
deadline: None,
},
WasmTransformOptions {
width: Some(40),
height: Some(30),
fit: Some("cover".to_string()),
position: Some("top".to_string()),
format: Some("jpeg".to_string()),
quality: Some(70),
optimize: None,
target_quality: None,
background: Some("010203".to_string()),
rotate: Some(90),
auto_orient: Some(true),
strip_metadata: Some(true),
keep_metadata: None,
preserve_exif: None,
crop: Some("1,2,30,40".to_string()),
blur: Some(1.0),
sharpen: Some(2.0),
grayscale: Some(false),
without_enlargement: Some(false),
},
)
}
#[allow(clippy::type_complexity)]
fn parity_cases() -> Vec<(&'static str, TransformOptions, WasmTransformOptions)> {
let m = |f: fn(&mut TransformOptions, &mut WasmTransformOptions)| {
let (mut a, mut b) = parity_base();
f(&mut a, &mut b);
(a, b)
};
let mut out = Vec::new();
let (a, b) = parity_base();
out.push(("base", a, b));
let (a, b) = m(|a, b| {
a.fit = Some(crate::Fit::Inside);
b.fit = Some("inside".to_string());
});
out.push(("fit-inside", a, b));
let (a, b) = m(|a, b| {
a.fit = Some(crate::Fit::Fill);
b.fit = Some("fill".to_string());
});
out.push(("fit-fill", a, b));
let (a, b) = m(|a, b| {
a.position = Some(Position::BottomRight);
b.position = Some("bottom-right".to_string());
});
out.push(("position", a, b));
let (a, b) = m(|a, b| {
a.format = Some(MediaType::Png);
a.quality = None;
b.format = Some("png".to_string());
b.quality = None;
});
out.push(("format-png", a, b));
let (a, b) = m(|a, b| {
a.format = Some(MediaType::Webp);
b.format = Some("webp".to_string());
});
out.push(("format-webp", a, b));
let (a, b) = m(|a, b| {
a.quality = None;
a.optimize = OptimizeMode::Auto;
b.quality = None;
b.optimize = Some("auto".to_string());
});
out.push(("optimize-auto", a, b));
let (a, b) = m(|a, b| {
a.format = Some(MediaType::Png);
a.quality = None;
a.optimize = OptimizeMode::Lossless;
b.format = Some("png".to_string());
b.quality = None;
b.optimize = Some("lossless".to_string());
});
out.push(("optimize-lossless", a, b));
let (a, b) = m(|a, b| {
a.quality = None;
a.optimize = OptimizeMode::Lossy;
a.target_quality = Some(crate::TargetQuality {
metric: crate::QualityMetric::Psnr,
value: 30.0,
});
b.quality = None;
b.optimize = Some("lossy".to_string());
b.target_quality = Some("psnr:30".to_string());
});
out.push(("targetQuality", a, b));
let (a, b) = m(|a, b| {
a.background = Some(Rgba8 {
r: 200,
g: 30,
b: 40,
a: 128,
});
b.background = Some("C81E2880".to_string());
});
out.push(("background-alpha", a, b));
let (a, b) = m(|a, b| {
a.rotate = Rotation::from_degrees(45);
b.rotate = Some(45);
});
out.push(("rotate-45", a, b));
let (a, b) = m(|a, b| {
a.rotate = Rotation::from_degrees(-90);
b.rotate = Some(-90);
});
out.push(("rotate-negative", a, b));
let (a, b) = m(|a, b| {
a.auto_orient = false;
b.auto_orient = Some(false);
});
out.push(("autoOrient-off", a, b));
let (a, b) = m(|a, b| {
a.strip_metadata = false;
b.strip_metadata = Some(false);
});
out.push(("keepMetadata", a, b));
let (a, b) = m(|a, b| {
a.strip_metadata = false;
a.preserve_exif = true;
b.strip_metadata = None;
b.preserve_exif = Some(true);
});
out.push(("preserveExif", a, b));
let (a, b) = m(|a, b| {
a.crop = None;
b.crop = None;
});
out.push(("crop-none", a, b));
let (a, b) = m(|a, b| {
a.blur = None;
b.blur = None;
});
out.push(("blur-none", a, b));
let (a, b) = m(|a, b| {
a.sharpen = None;
b.sharpen = None;
});
out.push(("sharpen-none", a, b));
let (a, b) = m(|a, b| {
a.grayscale = true;
b.grayscale = Some(true);
});
out.push(("grayscale", a, b));
let (a, b) = m(|a, b| {
a.width = Some(400);
a.height = Some(300);
a.without_enlargement = true;
b.width = Some(400);
b.height = Some(300);
b.without_enlargement = Some(true);
});
out.push(("withoutEnlargement", a, b));
let (a, b) = m(|a, b| {
a.width = None;
a.height = None;
a.fit = None;
a.position = None;
b.width = None;
b.height = None;
b.fit = None;
b.position = None;
});
out.push(("no-geometry", a, b));
out
}
#[test]
fn wasm_options_answer_what_the_library_answers() {
let source = parity_source();
let mut mismatches: Vec<String> = Vec::new();
for (name, core_options, wasm_options) in parity_cases() {
let expected = {
let artifact =
sniff_artifact(RawArtifact::new(source.clone(), None)).expect("sniff");
transform(TransformRequest::new(artifact, core_options))
};
let actual = transform_browser_artifact(source.clone(), Some("png"), wasm_options);
match (expected, actual) {
(Ok(expected), Ok(actual)) => {
if expected.artifact.bytes != actual.bytes {
mismatches.push(format!(
"{name}: bytes differ (library {}, wasm {})",
expected.artifact.bytes.len(),
actual.bytes.len()
));
}
}
(Err(expected), Ok(_)) => {
mismatches.push(format!("{name}: library refused ({expected}) but wasm ok"));
}
(Ok(_), Err(actual)) => {
mismatches.push(format!("{name}: library ok but wasm refused ({actual})"));
}
(Err(expected), Err(actual)) => {
if expected.to_string() != actual.to_string() {
mismatches.push(format!(
"{name}: both refused with different words: library `{expected}` wasm `{actual}`"
));
}
}
}
}
assert!(mismatches.is_empty(), "{mismatches:#?}");
}
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
crate::test_support::flat_png(width, height)
}
#[test]
fn browser_capabilities_reflect_compile_time_features() {
let capabilities = browser_capabilities();
assert_eq!(capabilities.svg, cfg!(feature = "svg"));
assert_eq!(capabilities.webp_lossy, cfg!(feature = "webp-lossy"));
assert_eq!(capabilities.avif, cfg!(feature = "avif"));
}
#[test]
fn inspect_browser_artifact_reports_png_metadata() {
let response =
inspect_browser_artifact(png_bytes(4, 3), Some("png")).expect("inspect png artifact");
assert_eq!(response.artifact.media_type, "png");
assert_eq!(response.artifact.mime_type, "image/png");
assert_eq!(response.artifact.width, Some(4));
assert_eq!(response.artifact.height, Some(3));
assert_eq!(response.artifact.has_alpha, Some(true));
assert_eq!(response.artifact.orientation, None);
assert_eq!(response.artifact.oriented_width, Some(4));
assert_eq!(response.artifact.oriented_height, Some(3));
}
#[test]
fn inspect_browser_artifact_reports_the_oriented_dimensions() {
let bytes = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/integration/fixtures/exif-rotated.jpg"
))
.to_vec();
let response = inspect_browser_artifact(bytes, Some("jpeg")).expect("inspect jpeg");
assert_eq!(response.artifact.width, Some(40));
assert_eq!(response.artifact.height, Some(20));
assert_eq!(response.artifact.orientation, Some(6));
assert_eq!(response.artifact.oriented_width, Some(20));
assert_eq!(response.artifact.oriented_height, Some(40));
}
#[test]
fn transform_browser_artifact_converts_png_to_jpeg() {
let response = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("jpeg".to_string()),
width: Some(2),
..WasmTransformOptions::default()
},
)
.expect("transform png to jpeg");
assert_eq!(response.artifact.media_type, "jpeg");
assert_eq!(response.artifact.mime_type, "image/jpeg");
assert_eq!(response.artifact.width, Some(2));
assert_eq!(response.artifact.height, Some(2));
assert_eq!(response.suggested_extension, "jpg");
assert!(response.bytes.starts_with(&[0xFF, 0xD8]));
}
#[test]
fn parse_wasm_options_rejects_conflicting_metadata_flags() {
let error = parse_wasm_options(WasmTransformOptions {
keep_metadata: Some(true),
preserve_exif: Some(true),
..WasmTransformOptions::default()
})
.expect_err("conflicting metadata flags should fail");
assert_eq!(
error,
TransformError::InvalidOptions(
"keepMetadata and preserveExif cannot both be true".to_string()
)
);
}
#[rstest]
#[case::keep_all(Some(false), None, false, false)]
#[case::strip_all(Some(true), None, true, false)]
#[case::preserve_exif_wins(Some(true), Some(true), false, true)]
fn parse_wasm_options_reads_strip_metadata_like_the_server(
#[case] strip_metadata: Option<bool>,
#[case] preserve_exif: Option<bool>,
#[case] expected_strip: bool,
#[case] expected_preserve: bool,
) {
let options = parse_wasm_options(WasmTransformOptions {
strip_metadata,
preserve_exif,
..WasmTransformOptions::default()
})
.expect("stripMetadata should resolve");
assert_eq!(options.strip_metadata, expected_strip);
assert_eq!(options.preserve_exif, expected_preserve);
}
#[test]
fn wasm_options_json_accepts_strip_metadata() {
let options: WasmTransformOptions =
serde_json::from_str(r#"{"format":"jpeg","stripMetadata":false}"#)
.expect("stripMetadata is a known key");
assert_eq!(options.strip_metadata, Some(false));
}
#[test]
fn wasm_options_accept_every_key_the_server_accepts() {
let server_vocabulary = [
("width", "1"),
("height", "1"),
("fit", "\"cover\""),
("position", "\"top-left\""),
("format", "\"webp\""),
("quality", "80"),
("optimize", "\"lossy\""),
("targetQuality", "\"ssim:0.98\""),
("background", "\"ffffff\""),
("rotate", "90"),
("autoOrient", "true"),
("stripMetadata", "false"),
("preserveExif", "false"),
("crop", "\"0,0,1,1\""),
("blur", "1.5"),
("sharpen", "1.5"),
("grayscale", "true"),
("withoutEnlargement", "true"),
];
let json = format!(
"{{{}}}",
server_vocabulary
.iter()
.map(|(key, value)| format!("\"{key}\":{value}"))
.collect::<Vec<_>>()
.join(",")
);
let options: WasmTransformOptions =
serde_json::from_str(&json).expect("every server key is a Wasm key");
assert_eq!(options.strip_metadata, Some(false));
assert_eq!(options.without_enlargement, Some(true));
}
#[test]
fn raster_input_cannot_request_svg_output() {
let error = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("svg".to_string()),
..WasmTransformOptions::default()
},
)
.expect_err("raster input should not produce svg output");
assert_eq!(
error,
TransformError::UnsupportedOutputMediaType(MediaType::Svg)
);
}
#[test]
fn test_resolve_wasm_watermark_rejects_svg() {
let svg_bytes = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>".to_vec();
let error = resolve_wasm_watermark(svg_bytes, WasmWatermarkOptions::default())
.expect_err("SVG watermark should be rejected");
assert_eq!(
error,
TransformError::InvalidOptions(
"watermark image must be a raster format, not SVG".to_string()
)
);
}
#[test]
fn test_resolve_wasm_watermark_rejects_opacity_zero() {
let error = resolve_wasm_watermark(
png_bytes(2, 2),
WasmWatermarkOptions {
opacity: Some(0),
..WasmWatermarkOptions::default()
},
)
.expect_err("opacity 0 should be rejected");
assert_eq!(
error,
TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string()
)
);
}
#[test]
fn test_resolve_wasm_watermark_rejects_opacity_over_100() {
let error = resolve_wasm_watermark(
png_bytes(2, 2),
WasmWatermarkOptions {
opacity: Some(101),
..WasmWatermarkOptions::default()
},
)
.expect_err("opacity 101 should be rejected");
assert_eq!(
error,
TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string()
)
);
}
#[test]
fn test_resolve_wasm_watermark_defaults() {
let wm = resolve_wasm_watermark(png_bytes(2, 2), WasmWatermarkOptions::default())
.expect("valid watermark with defaults");
assert_eq!(wm.position, Position::BottomRight);
assert_eq!(wm.opacity, 50);
assert_eq!(wm.margin, 10);
}
#[test]
fn parse_wasm_options_parses_crop() {
let options = parse_wasm_options(WasmTransformOptions {
crop: Some("10,20,100,200".to_string()),
..WasmTransformOptions::default()
})
.expect("valid crop should parse");
let crop = options.crop.expect("crop should be set");
assert_eq!(crop.x, 10);
assert_eq!(crop.y, 20);
assert_eq!(crop.width, 100);
assert_eq!(crop.height, 200);
}
#[test]
fn parse_wasm_options_rejects_invalid_crop() {
let error = parse_wasm_options(WasmTransformOptions {
crop: Some("bad".to_string()),
..WasmTransformOptions::default()
})
.expect_err("invalid crop should fail");
assert!(
matches!(error, TransformError::InvalidOptions(ref msg) if msg.contains("crop")),
"unexpected error: {error}"
);
}
#[test]
fn parse_wasm_options_accepts_optimize_and_target_quality() {
let options = parse_wasm_options(WasmTransformOptions {
format: Some("jpeg".to_string()),
optimize: Some("lossy".to_string()),
target_quality: Some("ssim:0.98".to_string()),
..WasmTransformOptions::default()
})
.expect("optimize fields should parse");
assert_eq!(options.optimize, OptimizeMode::Lossy);
assert_eq!(
options
.target_quality
.expect("target quality should exist")
.to_string(),
"ssim:0.98"
);
}
#[test]
fn parse_wasm_options_rejects_invalid_optimize() {
let error = parse_wasm_options(WasmTransformOptions {
optimize: Some("fast".to_string()),
..WasmTransformOptions::default()
})
.expect_err("invalid optimize should fail");
assert!(
matches!(error, TransformError::InvalidOptions(ref msg) if msg.contains("optimize")),
"unexpected error: {error}"
);
}
#[test]
fn parse_wasm_options_rejects_invalid_target_quality() {
let error = parse_wasm_options(WasmTransformOptions {
target_quality: Some("ssim:abc".to_string()),
..WasmTransformOptions::default()
})
.expect_err("invalid targetQuality should fail");
assert!(
matches!(error, TransformError::InvalidOptions(ref msg) if msg.contains("targetQuality")),
"unexpected error: {error}"
);
}
#[test]
fn test_transform_with_watermark_basic() {
let response = transform_browser_artifact_with_watermark(
png_bytes(16, 16),
None,
WasmTransformOptions::default(),
png_bytes(4, 4),
WasmWatermarkOptions {
position: Some("center".to_string()),
opacity: Some(80),
margin: Some(0),
},
)
.expect("transform with watermark should succeed");
assert_eq!(response.artifact.media_type, "png");
assert_eq!(response.artifact.width, Some(16));
assert_eq!(response.artifact.height, Some(16));
assert!(!response.bytes.is_empty());
assert!(response.bytes.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn output_extension_returns_expected_values() {
assert_eq!(output_extension(MediaType::Jpeg), "jpg");
assert_eq!(output_extension(MediaType::Png), "png");
assert_eq!(output_extension(MediaType::Webp), "webp");
assert_eq!(output_extension(MediaType::Avif), "avif");
assert_eq!(output_extension(MediaType::Svg), "svg");
assert_eq!(output_extension(MediaType::Bmp), "bmp");
assert_eq!(output_extension(MediaType::Tiff), "tiff");
}
#[test]
fn parse_rotation_accepts_valid_values() {
assert_eq!(parse_rotation(None).unwrap(), Rotation::DEG_0);
assert_eq!(parse_rotation(Some(0)).unwrap(), Rotation::DEG_0);
assert_eq!(parse_rotation(Some(90)).unwrap(), Rotation::DEG_90);
assert_eq!(parse_rotation(Some(180)).unwrap(), Rotation::DEG_180);
assert_eq!(parse_rotation(Some(270)).unwrap(), Rotation::DEG_270);
}
#[test]
fn parse_rotation_accepts_arbitrary_and_wrapping_angles() {
assert_eq!(parse_rotation(Some(45)).unwrap().as_degrees(), 45);
assert_eq!(parse_rotation(Some(-90)).unwrap(), Rotation::DEG_270);
assert_eq!(parse_rotation(Some(370)).unwrap().as_degrees(), 10);
assert_eq!(parse_rotation(Some(-360)).unwrap(), Rotation::DEG_0);
}
#[test]
fn inspect_rejects_garbage_bytes() {
let error = inspect_browser_artifact(vec![0xDE, 0xAD, 0xBE, 0xEF], None)
.expect_err("garbage bytes should fail");
assert!(matches!(
error,
TransformError::UnsupportedInputMediaType(_)
));
}
#[test]
fn wasm_transform_options_serde_roundtrip() {
let options = WasmTransformOptions {
width: Some(800),
height: Some(600),
format: Some("jpeg".to_string()),
quality: Some(85),
rotate: Some(90),
auto_orient: Some(false),
blur: Some(1.5),
sharpen: Some(2.0),
..WasmTransformOptions::default()
};
let json = serde_json::to_string(&options).expect("serialize");
let parsed: WasmTransformOptions = serde_json::from_str(&json).expect("deserialize");
assert_eq!(options, parsed);
}
#[test]
fn wasm_watermark_options_serde_roundtrip() {
let options = WasmWatermarkOptions {
position: Some("top-left".to_string()),
opacity: Some(75),
margin: Some(20),
};
let json = serde_json::to_string(&options).expect("serialize");
let parsed: WasmWatermarkOptions = serde_json::from_str(&json).expect("deserialize");
assert_eq!(options, parsed);
}
#[test]
fn wasm_docs_reference_current_js_exports() {
for export_name in [
"getCapabilitiesJson",
"inspectImageJson",
"transformImage",
"transformImageWithWatermark",
"responseJson",
] {
assert!(
WASM_DOCS.contains(export_name),
"docs/wasm.md should mention {export_name}"
);
}
}
#[test]
fn wasm_kinds_are_the_camel_case_problem_slugs() {
const PROBLEM_DOCS: &str = include_str!("../../docs/problems.md");
let problem_docs = PROBLEM_DOCS.replace('\r', "");
let cases: [(TransformError, &str, &str); 8] = [
(
TransformError::InvalidOptions("x".into()),
"invalid-options",
"invalidOptions",
),
(
TransformError::InvalidInput("x".into()),
"invalid-input",
"invalidInput",
),
(
TransformError::DecodeFailed("x".into()),
"decode-failed",
"decodeFailed",
),
(
TransformError::UnsupportedInputMediaType("x".into()),
"unsupported-input-media-type",
"unsupportedInputMediaType",
),
(
TransformError::UnsupportedOutputMediaType(MediaType::Gif),
"unsupported-output-media-type",
"unsupportedOutputMediaType",
),
(
TransformError::EncodeFailed("x".into()),
"encode-failed",
"encodeFailed",
),
(
TransformError::CapabilityMissing("x".into()),
"capability-missing",
"capabilityMissing",
),
(
TransformError::LimitExceeded("x".into()),
"limit-exceeded",
"limitExceeded",
),
];
for (error, slug, kind) in cases {
assert_eq!(error.class().slug(), slug, "{error:?}");
assert_eq!(error_kind(&error), kind, "{error:?}");
assert!(
problem_docs.contains(&format!("### {slug}\n")),
"docs/problems.md should document the {slug} class"
);
assert!(
WASM_DOCS.contains(kind),
"docs/wasm.md should document the {kind} kind"
);
}
}
#[test]
fn transform_png_to_bmp() {
let response = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("bmp".to_string()),
..WasmTransformOptions::default()
},
)
.expect("transform png to bmp");
assert_eq!(response.artifact.media_type, "bmp");
assert_eq!(response.suggested_extension, "bmp");
assert!(!response.bytes.is_empty());
}
#[test]
fn transform_png_to_tiff() {
let response = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("tiff".to_string()),
..WasmTransformOptions::default()
},
)
.expect("transform png to tiff");
assert_eq!(response.artifact.media_type, "tiff");
assert_eq!(response.suggested_extension, "tiff");
assert!(!response.bytes.is_empty());
}
#[test]
fn transform_png_to_webp_lossless() {
let response = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("webp".to_string()),
..WasmTransformOptions::default()
},
)
.expect("transform png to webp");
assert_eq!(response.artifact.media_type, "webp");
assert_eq!(response.suggested_extension, "webp");
assert!(!response.bytes.is_empty());
}
#[test]
fn transform_with_resize_and_rotate() {
let response = transform_browser_artifact(
png_bytes(8, 6),
None,
WasmTransformOptions {
width: Some(4),
height: Some(3),
rotate: Some(90),
format: Some("png".to_string()),
..WasmTransformOptions::default()
},
)
.expect("resize and rotate");
assert_eq!(response.artifact.media_type, "png");
assert!(response.artifact.width.is_some());
assert!(response.artifact.height.is_some());
}
#[cfg(feature = "avif")]
#[test]
fn transform_png_to_avif() {
let response = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("avif".to_string()),
quality: Some(72),
..WasmTransformOptions::default()
},
)
.expect("transform png to avif");
assert_eq!(response.artifact.media_type, "avif");
assert_eq!(response.suggested_extension, "avif");
assert!(!response.bytes.is_empty());
}
#[cfg(feature = "avif")]
#[test]
fn transform_avif_round_trip() {
let avif = transform_browser_artifact(
png_bytes(4, 3),
Some("png"),
WasmTransformOptions {
format: Some("avif".to_string()),
..WasmTransformOptions::default()
},
)
.expect("png to avif");
let png = transform_browser_artifact(
avif.bytes,
Some("avif"),
WasmTransformOptions {
format: Some("png".to_string()),
..WasmTransformOptions::default()
},
)
.expect("avif to png");
assert_eq!(png.artifact.media_type, "png");
assert!(png.artifact.width.is_some());
}
#[test]
fn parse_wasm_options_rejects_invalid_background() {
let error = parse_wasm_options(WasmTransformOptions {
background: Some("ZZZZZZ".to_string()),
..WasmTransformOptions::default()
})
.expect_err("invalid background should fail");
assert!(matches!(
error,
TransformError::InvalidOptions(ref msg) if msg.contains("background")
));
}
#[test]
fn parse_wasm_options_rejects_invalid_format() {
let error = parse_wasm_options(WasmTransformOptions {
format: Some("heic".to_string()),
..WasmTransformOptions::default()
})
.expect_err("invalid format should fail");
assert!(matches!(error, TransformError::InvalidOptions(_)));
}
#[test]
fn parse_wasm_options_rejects_a_decode_only_output_format() {
let error = parse_wasm_options(WasmTransformOptions {
format: Some("gif".to_string()),
..WasmTransformOptions::default()
})
.expect_err("gif output should fail");
assert_eq!(
error,
TransformError::UnsupportedOutputMediaType(MediaType::Gif)
);
assert_eq!(error.class().slug(), "unsupported-output-media-type");
assert!(error.to_string().contains("input-only"), "{error}");
}
#[test]
fn parse_wasm_options_accepts_blur_and_sharpen() {
let options = parse_wasm_options(WasmTransformOptions {
blur: Some(5.0),
sharpen: Some(3.0),
..WasmTransformOptions::default()
})
.expect("blur and sharpen should parse");
assert_eq!(options.blur, Some(5.0));
assert_eq!(options.sharpen, Some(3.0));
}
}