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 {
pub width: Option<u32>,
pub height: Option<u32>,
pub fit: Option<String>,
pub position: Option<String>,
pub format: Option<String>,
pub quality: Option<u8>,
pub optimize: Option<String>,
pub target_quality: Option<String>,
pub background: Option<String>,
pub rotate: Option<i32>,
pub auto_orient: 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(None, 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.is_encodable() {
Ok(media_type)
} else {
Err(TransformError::InvalidOptions(format!(
"{field} is invalid: {} is an input-only format",
media_type.as_name()
)))
}
}
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(feature = "wasm")]
fn error_kind(error: &TransformError) -> &'static str {
match error {
TransformError::InvalidInput(_) => "invalidInput",
TransformError::InvalidOptions(_) => "invalidOptions",
TransformError::UnsupportedInputMediaType(_) => "unsupportedInputMediaType",
TransformError::UnsupportedOutputMediaType(_) => "unsupportedOutputMediaType",
TransformError::DecodeFailed(_) => "decodeFailed",
TransformError::EncodeFailed(_) => "encodeFailed",
TransformError::CapabilityMissing(_) => "capabilityMissing",
TransformError::LimitExceeded(_) => "limitExceeded",
}
}
#[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: error.to_string(),
};
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 position = watermark_options
.position
.map(|v| {
Position::from_str(&v).map_err(|reason| {
TransformError::InvalidOptions(format!("watermark position is invalid: {reason}"))
})
})
.transpose()?
.unwrap_or(Position::BottomRight);
let opacity = watermark_options.opacity.unwrap_or(50);
if opacity == 0 || opacity > 100 {
return Err(TransformError::InvalidOptions(
"watermark opacity must be between 1 and 100".to_string(),
));
}
let margin = watermark_options.margin.unwrap_or(10);
Ok(WatermarkInput {
image: artifact,
position,
opacity,
margin,
})
}
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 image::codecs::png::PngEncoder;
use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
const WASM_DOCS: &str = include_str!("../../docs/wasm.md");
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(&image, width, height, ColorType::Rgba8.into())
.expect("encode png");
bytes
}
#[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()
)
);
}
#[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 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");
match error {
TransformError::InvalidOptions(message) => assert!(
message.contains("input-only"),
"the error should say why, got: {message}"
),
other => panic!("expected InvalidOptions, got: {other}"),
}
}
#[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));
}
}