Skip to content
218 changes: 192 additions & 26 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use web_sys::console;

use image::codecs::webp::WebPEncoder;
use image::{
imageops::resize,
imageops::FilterType,
GenericImageView,
ImageReader,
Expand All @@ -20,6 +19,65 @@ use std::io::Cursor;
use serde::Deserialize;
use serde_wasm_bindgen::from_value;

type ToolkitResult<T> = Result<T, ToolkitError>;
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<String> {
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<ToolkitError> 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<u32>,
Expand All @@ -32,17 +90,24 @@ struct ResizeOptions {

#[wasm_bindgen]
pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, JsValue> {
resize_image_impl(data, options).map_err(JsValue::from)
}

fn resize_image_impl(data: &[u8], options: JsValue) -> ToolkitResult<Box<[u8]>> {
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<Box<[u8]>> {
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();
Expand All @@ -52,10 +117,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, 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)
Expand All @@ -67,9 +129,7 @@ pub fn resize_image(data: &[u8], options: JsValue) -> Result<Box<[u8]>, 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())
}
Expand All @@ -85,28 +145,31 @@ fn get_filter_type(level: u32) -> FilterType {
}

fn parse_format(fmt: &str) -> Option<ImageFormat> {
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
}
}

fn encode_image(
image: &DynamicImage,
format: &ImageFormat,
options: &ResizeOptions
) -> Result<Vec<u8>, JsValue> {
) -> ToolkitResult<Vec<u8>> {
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)?;
Expand All @@ -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<u8>) -> Result<(), JsValue> {
fn normalized_quality_u8(raw_quality: Option<f32>) -> 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<u8>) -> ToolkitResult<()> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();

Expand All @@ -136,22 +209,22 @@ fn encode_as_png(image: &DynamicImage, buffer: &mut Vec<u8>) -> 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<u8>
) -> 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
})
}

Expand All @@ -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<u8> {
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() {
Expand Down Expand Up @@ -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);
}
}
9 changes: 5 additions & 4 deletions ts-wrapper/resizeImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}