diff --git a/src/lib.rs b/src/lib.rs index 11590f1..ead3714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,6 @@ use web_sys::console; use image::codecs::webp::WebPEncoder; use image::{ - imageops::resize, imageops::FilterType, GenericImageView, ImageReader, @@ -20,6 +19,65 @@ use std::io::Cursor; use serde::Deserialize; use serde_wasm_bindgen::from_value; +type ToolkitResult = Result; +const DEFAULT_QUALITY: f32 = 0.7; + +#[derive(Debug)] +enum ToolkitError { + InvalidOptions(String), + FormatGuessFailed(String), + DecodeFailed(String), + UnsupportedFormat, + JpegEncodeFailed(String), + PngEncodeFailed(String), + WebpEncodeFailed, + WriteFailed(String), +} + +impl ToolkitError { + fn user_message(&self) -> &'static str { + match self { + ToolkitError::InvalidOptions(_) => "Invalid options", + ToolkitError::UnsupportedFormat => "Unsupported format", + _ => "Image processing failed", + } + } + + fn internal_log_message(&self) -> Option { + match self { + ToolkitError::InvalidOptions(e) => { + Some(format!("[img-toolkit] Invalid options error: {}", e)) + } + ToolkitError::FormatGuessFailed(e) => { + Some(format!("[img-toolkit] Format guess failed with internal error: {}", e)) + } + ToolkitError::DecodeFailed(e) => { + Some(format!("[img-toolkit] Decode failed with internal error: {}", e)) + } + ToolkitError::JpegEncodeFailed(e) => { + Some(format!("[img-toolkit] JPEG encode failed with internal error: {}", e)) + } + ToolkitError::PngEncodeFailed(e) => { + Some(format!("[img-toolkit] PNG encode failed with internal error: {}", e)) + } + ToolkitError::WriteFailed(e) => { + Some(format!("[img-toolkit] Image write failed with internal error: {}", e)) + } + ToolkitError::UnsupportedFormat | ToolkitError::WebpEncodeFailed => None, + } + } +} + +impl From for JsValue { + fn from(error: ToolkitError) -> Self { + if let Some(log) = error.internal_log_message() { + console::error_1(&JsValue::from_str(&log)); + } + + JsValue::from_str(error.user_message()) + } +} + #[derive(Deserialize)] struct ResizeOptions { width: Option, @@ -32,17 +90,24 @@ struct ResizeOptions { #[wasm_bindgen] pub fn resize_image(data: &[u8], options: JsValue) -> Result, JsValue> { + resize_image_impl(data, options).map_err(JsValue::from) +} + +fn resize_image_impl(data: &[u8], options: JsValue) -> ToolkitResult> { let options: ResizeOptions = from_value(options).map_err(|e| - JsValue::from_str(&format!("Invalid options: {}", e)) + ToolkitError::InvalidOptions(e.to_string()) )?; + resize_image_with_options(data, options) +} +fn resize_image_with_options(data: &[u8], options: ResizeOptions) -> ToolkitResult> { let value = map_brightness(options.brightness); let img = ImageReader::new(Cursor::new(data)) .with_guessed_format() - .map_err(|e| JsValue::from_str(&format!("Format guess failed: {}", e)))? + .map_err(|e| ToolkitError::FormatGuessFailed(e.to_string()))? .decode() - .map_err(|e| JsValue::from_str(&format!("Decode failed: {}", e)))? + .map_err(|e| ToolkitError::DecodeFailed(e.to_string()))? .brighten(value); let (orig_w, orig_h) = img.dimensions(); @@ -52,10 +117,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result, JsValue> let width = options.width.filter(|&w| w > 0); let height = options.height.filter(|&h| h > 0); let resized = match (width, height) { - (Some(w), Some(h)) => { - let buf = resize(&img.to_rgba8(), w, h, filter); - DynamicImage::ImageRgba8(buf) - } + (Some(w), Some(h)) => img.resize_exact(w, h, filter), (Some(w), None) => { let h = scaled_height_for_width(w, orig_w, orig_h); img.resize(w, h, filter) @@ -67,9 +129,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result, JsValue> (None, None) => img, }; - let format = parse_format(&options.format).ok_or_else(|| - JsValue::from_str("Unsupported format") - )?; + let format = parse_format(&options.format).ok_or(ToolkitError::UnsupportedFormat)?; let buffer = encode_image(&resized, &format, &options)?; Ok(buffer.into_boxed_slice()) } @@ -85,11 +145,14 @@ fn get_filter_type(level: u32) -> FilterType { } fn parse_format(fmt: &str) -> Option { - match fmt.to_lowercase().as_str() { - "png" => Some(ImageFormat::Png), - "jpeg" | "jpg" => Some(ImageFormat::Jpeg), - "webp" => Some(ImageFormat::WebP), - _ => None, + if fmt.eq_ignore_ascii_case("png") { + Some(ImageFormat::Png) + } else if fmt.eq_ignore_ascii_case("jpeg") || fmt.eq_ignore_ascii_case("jpg") { + Some(ImageFormat::Jpeg) + } else if fmt.eq_ignore_ascii_case("webp") { + Some(ImageFormat::WebP) + } else { + None } } @@ -97,16 +160,16 @@ fn encode_image( image: &DynamicImage, format: &ImageFormat, options: &ResizeOptions -) -> Result, JsValue> { +) -> ToolkitResult> { let mut buffer = Vec::new(); - let quality = (options.quality.unwrap_or(0.8) * 100.0).round().clamp(1.0, 100.0) as u8; + let quality = normalized_quality_u8(options.quality); match format { ImageFormat::Jpeg => { let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality); encoder .encode_image(image) - .map_err(|e| JsValue::from_str(&format!("JPEG encode failed: {}", e)))?; + .map_err(|e| ToolkitError::JpegEncodeFailed(e.to_string()))?; } ImageFormat::Png => { encode_as_png(image, &mut buffer)?; @@ -117,14 +180,24 @@ fn encode_image( _ => { image .write_to(&mut Cursor::new(&mut buffer), *format) - .map_err(|e| JsValue::from_str(&format!("Write failed: {}", e)))?; + .map_err(|e| ToolkitError::WriteFailed(e.to_string()))?; } } Ok(buffer) } -fn encode_as_png(image: &DynamicImage, buffer: &mut Vec) -> Result<(), JsValue> { +fn normalized_quality_u8(raw_quality: Option) -> u8 { + let quality_f = raw_quality.unwrap_or(DEFAULT_QUALITY); + let quality_f = if quality_f.is_finite() { + quality_f + } else { + DEFAULT_QUALITY + }; + (quality_f * 100.0).round().clamp(1.0, 100.0) as u8 +} + +fn encode_as_png(image: &DynamicImage, buffer: &mut Vec) -> ToolkitResult<()> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); @@ -136,22 +209,22 @@ fn encode_as_png(image: &DynamicImage, buffer: &mut Vec) -> Result<(), JsVal encoder .write_image(&rgba, w, h, ExtendedColorType::Rgba8) - .map_err(|e| JsValue::from_str(&format!("PNG encode failed: {}", e))) + .map_err(|e| ToolkitError::PngEncodeFailed(e.to_string())) } fn encode_as_webp( image: &DynamicImage, buffer: &mut Vec -) -> Result<(), JsValue> { +) -> ToolkitResult<()> { let rgba = image.to_rgba8(); let (width, height) = rgba.dimensions(); let encoder = WebPEncoder::new_lossless(buffer); encoder .encode(&rgba, width, height, ExtendedColorType::Rgba8) - .map_err(|e| { - console::error_1(&JsValue::from_str(&format!("WebP encode failed: {}", e))); - JsValue::from_str("Image encoding failed") + .map_err(|_| { + console::error_1(&JsValue::from_str("[img-toolkit][ERR_WEBP_ENCODE] encode failed")); + ToolkitError::WebpEncodeFailed }) } @@ -172,6 +245,24 @@ fn scaled_width_for_height(height: u32, orig_w: u32, orig_h: u32) -> u32 { #[cfg(test)] mod tests { use super::*; + use image::RgbaImage; + use std::io::Cursor; + + fn make_test_png(width: u32, height: u32) -> Vec { + let rgba = RgbaImage::from_pixel(width, height, image::Rgba([120, 80, 200, 255])); + let img = DynamicImage::ImageRgba8(rgba); + let mut bytes = Vec::new(); + img.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png).unwrap(); + bytes + } + + fn decode_image(bytes: &[u8]) -> (ImageFormat, DynamicImage) { + let mut reader = ImageReader::new(Cursor::new(bytes)); + reader = reader.with_guessed_format().unwrap(); + let format = reader.format().unwrap(); + let image = reader.decode().unwrap(); + (format, image) + } #[test] fn map_brightness_clamps_range() { @@ -210,4 +301,79 @@ mod tests { assert_eq!(scaled_height_for_width(400, 1200, 800), 267); assert_eq!(scaled_width_for_height(267, 1200, 800), 401); } + + #[test] + fn resize_image_exact_dimensions_encodes_as_jpeg() { + let input = make_test_png(120, 80); + let options = ResizeOptions { + width: Some(64), + height: Some(64), + quality: None, + format: "jpg".to_string(), + brightness: 0.5, + resampling: 4, + }; + + let output = resize_image_with_options(&input, options).unwrap(); + let (format, decoded) = decode_image(&output); + + assert_eq!(format, ImageFormat::Jpeg); + assert_eq!(decoded.dimensions(), (64, 64)); + } + + #[test] + fn resize_image_single_dimension_preserves_aspect_ratio() { + let input = make_test_png(120, 80); + let options = ResizeOptions { + width: Some(60), + height: None, + quality: Some(0.7), + format: "png".to_string(), + brightness: 0.5, + resampling: 4, + }; + + let output = resize_image_with_options(&input, options).unwrap(); + let (format, decoded) = decode_image(&output); + + assert_eq!(format, ImageFormat::Png); + assert_eq!(decoded.dimensions(), (60, 40)); + } + + #[test] + fn resize_image_rejects_unsupported_format() { + let input = make_test_png(32, 32); + let options = ResizeOptions { + width: Some(32), + height: Some(32), + quality: None, + format: "gif".to_string(), + brightness: 0.5, + resampling: 4, + }; + + let err = resize_image_with_options(&input, options).unwrap_err(); + assert!(matches!(err, ToolkitError::UnsupportedFormat)); + } + + #[test] + fn toolkit_error_user_messages_follow_exposure_policy() { + assert_eq!(ToolkitError::InvalidOptions("x".to_string()).user_message(), "Invalid options"); + assert_eq!(ToolkitError::UnsupportedFormat.user_message(), "Unsupported format"); + assert_eq!( + ToolkitError::DecodeFailed("x".to_string()).user_message(), + "Image processing failed" + ); + assert_eq!(ToolkitError::WebpEncodeFailed.user_message(), "Image processing failed"); + } + + #[test] + fn quality_normalization_rejects_non_finite_values() { + assert_eq!(normalized_quality_u8(None), 70); + assert_eq!(normalized_quality_u8(Some(f32::NAN)), 70); + assert_eq!(normalized_quality_u8(Some(f32::INFINITY)), 70); + assert_eq!(normalized_quality_u8(Some(f32::NEG_INFINITY)), 70); + assert_eq!(normalized_quality_u8(Some(0.0)), 1); + assert_eq!(normalized_quality_u8(Some(1.0)), 100); + } } diff --git a/ts-wrapper/resizeImage.ts b/ts-wrapper/resizeImage.ts index f73eaf8..59c7f75 100644 --- a/ts-wrapper/resizeImage.ts +++ b/ts-wrapper/resizeImage.ts @@ -130,9 +130,9 @@ async function processWithWasm( const sanitizedOptions = { ...options, format: wasmFormat, - quality: clamp(options.quality ?? 0.7, 0, 1), - brightness: clamp(options.brightness ?? 0.5, 0, 1), - resampling: clamp(options.resampling ?? 4, 0, 10), + quality: clamp(options.quality ?? 0.7, 0, 1, 0.7), + brightness: clamp(options.brightness ?? 0.5, 0, 1, 0.5), + resampling: clamp(options.resampling ?? 4, 0, 10, 4), }; const buffer = await file.arrayBuffer(); @@ -239,6 +239,7 @@ function inferFormatFromFile(file: File): ImageFormat { return "jpg"; } -function clamp(value: number, min: number, max: number): number { +function clamp(value: number, min: number, max: number, fallback = min): number { + if (!Number.isFinite(value)) return fallback; return Math.min(Math.max(value, min), max); }