From 7d986d13535df08621939c69958659149183d5c1 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:25:07 +0900 Subject: [PATCH 1/8] refactor(rust): unify resize path with DynamicImage resize_exact --- src/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 11590f1..2ab0c36 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, @@ -52,10 +51,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) From 0043ab72175e7888d8466ce310b90412333ebda5 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:29:02 +0900 Subject: [PATCH 2/8] refactor(rust): align default quality with ts wrapper (0.7) --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 2ab0c36..3598292 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,7 +95,7 @@ fn encode_image( options: &ResizeOptions ) -> Result, JsValue> { 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 = (options.quality.unwrap_or(0.7) * 100.0).round().clamp(1.0, 100.0) as u8; match format { ImageFormat::Jpeg => { From 52b237db4fdbb3722e24ed2d170e0777b0497f97 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:30:31 +0900 Subject: [PATCH 3/8] refactor(rust): use ascii-insensitive format parsing without allocation --- src/lib.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3598292..208ed3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,11 +81,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 } } From 6dd4c84f2c982b76dbae894c608f5adbdfd1e236 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:34:02 +0900 Subject: [PATCH 4/8] refactor(rust): centralize wasm error handling with typed internal errors --- src/lib.rs | 62 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 208ed3a..0660a6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,40 @@ use std::io::Cursor; use serde::Deserialize; use serde_wasm_bindgen::from_value; +type ToolkitResult = Result; + +enum ToolkitError { + InvalidOptions(String), + FormatGuessFailed(String), + DecodeFailed(String), + UnsupportedFormat, + JpegEncodeFailed(String), + PngEncodeFailed(String), + WebpEncodeFailed, + WriteFailed(String), +} + +impl From for JsValue { + fn from(error: ToolkitError) -> Self { + match error { + ToolkitError::InvalidOptions(e) => JsValue::from_str(&format!("Invalid options: {}", e)), + ToolkitError::FormatGuessFailed(e) => { + JsValue::from_str(&format!("Format guess failed: {}", e)) + } + ToolkitError::DecodeFailed(e) => JsValue::from_str(&format!("Decode failed: {}", e)), + ToolkitError::UnsupportedFormat => JsValue::from_str("Unsupported format"), + ToolkitError::JpegEncodeFailed(e) => { + JsValue::from_str(&format!("JPEG encode failed: {}", e)) + } + ToolkitError::PngEncodeFailed(e) => { + JsValue::from_str(&format!("PNG encode failed: {}", e)) + } + ToolkitError::WebpEncodeFailed => JsValue::from_str("Image encoding failed"), + ToolkitError::WriteFailed(e) => JsValue::from_str(&format!("Write failed: {}", e)), + } + } +} + #[derive(Deserialize)] struct ResizeOptions { width: Option, @@ -31,17 +65,21 @@ 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()) )?; 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(); @@ -63,9 +101,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()) } @@ -96,7 +132,7 @@ fn encode_image( image: &DynamicImage, format: &ImageFormat, options: &ResizeOptions -) -> Result, JsValue> { +) -> ToolkitResult> { let mut buffer = Vec::new(); let quality = (options.quality.unwrap_or(0.7) * 100.0).round().clamp(1.0, 100.0) as u8; @@ -105,7 +141,7 @@ fn encode_image( 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)?; @@ -116,14 +152,14 @@ 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 encode_as_png(image: &DynamicImage, buffer: &mut Vec) -> ToolkitResult<()> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); @@ -135,13 +171,13 @@ 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(); @@ -150,7 +186,7 @@ fn encode_as_webp( .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") + ToolkitError::WebpEncodeFailed }) } From aa532ca3c1eb40e048315a969a7b70a2348eae56 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:38:16 +0900 Subject: [PATCH 5/8] test(rust): add e2e coverage for resize pipeline --- src/lib.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 0660a6a..ea82d4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ use serde_wasm_bindgen::from_value; type ToolkitResult = Result; +#[derive(Debug)] enum ToolkitError { InvalidOptions(String), FormatGuessFailed(String), @@ -72,7 +73,10 @@ fn resize_image_impl(data: &[u8], options: JsValue) -> ToolkitResult> let options: ResizeOptions = from_value(options).map_err(|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)) @@ -207,6 +211,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() { @@ -245,4 +267,58 @@ 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)); + } } From d59ebda04a4a9af2ca201ba89205577a27a87739 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:50:17 +0900 Subject: [PATCH 6/8] fix(rust): enforce safe user-facing wasm error messages --- src/lib.rs | 56 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ea82d4d..f02383e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,24 +33,47 @@ enum ToolkitError { WriteFailed(String), } -impl From for JsValue { - fn from(error: ToolkitError) -> Self { - match error { - ToolkitError::InvalidOptions(e) => JsValue::from_str(&format!("Invalid options: {}", e)), +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) => { - JsValue::from_str(&format!("Format guess failed: {}", 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::DecodeFailed(e) => JsValue::from_str(&format!("Decode failed: {}", e)), - ToolkitError::UnsupportedFormat => JsValue::from_str("Unsupported format"), ToolkitError::JpegEncodeFailed(e) => { - JsValue::from_str(&format!("JPEG encode failed: {}", e)) + Some(format!("[img-toolkit] JPEG encode failed with internal error: {}", e)) } ToolkitError::PngEncodeFailed(e) => { - JsValue::from_str(&format!("PNG encode failed: {}", e)) + Some(format!("[img-toolkit] PNG encode failed with internal error: {}", e)) } - ToolkitError::WebpEncodeFailed => JsValue::from_str("Image encoding failed"), - ToolkitError::WriteFailed(e) => JsValue::from_str(&format!("Write failed: {}", 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()) } } @@ -321,4 +344,15 @@ mod tests { 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"); + } } From b4dcf5edfd75eb7f703f98d0751301e207c88279 Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:52:18 +0900 Subject: [PATCH 7/8] fix(rust): remove raw webp encoder error logging in wasm --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f02383e..9d2bde7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -211,8 +211,8 @@ fn encode_as_webp( 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))); + .map_err(|_| { + console::error_1(&JsValue::from_str("[img-toolkit][ERR_WEBP_ENCODE] encode failed")); ToolkitError::WebpEncodeFailed }) } From d6f26dbdbd6a7d0352a188cbd99bed1151d6e61e Mon Sep 17 00:00:00 2001 From: Yonghun Yi Date: Mon, 9 Feb 2026 09:54:26 +0900 Subject: [PATCH 8/8] fix: guard non-finite quality values across wasm and ts wrapper --- src/lib.rs | 23 ++++++++++++++++++++++- ts-wrapper/resizeImage.ts | 9 +++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9d2bde7..ead3714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ use serde::Deserialize; use serde_wasm_bindgen::from_value; type ToolkitResult = Result; +const DEFAULT_QUALITY: f32 = 0.7; #[derive(Debug)] enum ToolkitError { @@ -161,7 +162,7 @@ fn encode_image( options: &ResizeOptions ) -> ToolkitResult> { let mut buffer = Vec::new(); - let quality = (options.quality.unwrap_or(0.7) * 100.0).round().clamp(1.0, 100.0) as u8; + let quality = normalized_quality_u8(options.quality); match format { ImageFormat::Jpeg => { @@ -186,6 +187,16 @@ fn encode_image( Ok(buffer) } +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(); @@ -355,4 +366,14 @@ mod tests { ); 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); }