1use crate::{
8 Artifact, MediaType, RawArtifact, Rgba8, Rotation, TransformError, TransformOptions,
9 TransformRequest, TransformResult, sniff_artifact, transform_raster,
10};
11use serde::{Deserialize, Serialize};
12use std::str::FromStr;
13
14#[cfg(feature = "svg")]
15use crate::transform_svg;
16#[cfg(feature = "wasm")]
17use wasm_bindgen::prelude::*;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct WasmTransformOptions {
27 pub width: Option<u32>,
29 pub height: Option<u32>,
31 pub fit: Option<String>,
33 pub position: Option<String>,
35 pub format: Option<String>,
37 pub quality: Option<u8>,
39 pub background: Option<String>,
41 pub rotate: Option<u16>,
43 pub auto_orient: Option<bool>,
45 pub keep_metadata: Option<bool>,
47 pub preserve_exif: Option<bool>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct WasmCapabilities {
58 pub svg: bool,
60 pub webp_lossy: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct WasmArtifactInfo {
68 pub media_type: String,
70 pub mime_type: String,
72 pub width: Option<u32>,
74 pub height: Option<u32>,
76 pub frame_count: u32,
78 pub has_alpha: Option<bool>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct WasmInspectResponse {
86 pub artifact: WasmArtifactInfo,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct WasmTransformResponse {
94 pub bytes: Vec<u8>,
96 pub artifact: WasmArtifactInfo,
98 pub warnings: Vec<String>,
100 pub suggested_extension: String,
102}
103
104#[cfg(feature = "wasm")]
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107struct WasmErrorPayload {
108 kind: &'static str,
109 message: String,
110}
111
112pub fn browser_capabilities() -> WasmCapabilities {
114 WasmCapabilities {
115 svg: cfg!(feature = "svg"),
116 webp_lossy: cfg!(feature = "webp-lossy"),
117 }
118}
119
120pub fn inspect_browser_artifact(
132 input_bytes: Vec<u8>,
133 declared_media_type: Option<&str>,
134) -> Result<WasmInspectResponse, TransformError> {
135 let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
136
137 Ok(WasmInspectResponse {
138 artifact: artifact_info(&artifact),
139 })
140}
141
142pub fn transform_browser_artifact(
154 input_bytes: Vec<u8>,
155 declared_media_type: Option<&str>,
156 options: WasmTransformOptions,
157) -> Result<WasmTransformResponse, TransformError> {
158 let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
159 let options = parse_wasm_options(options)?;
160 let output = dispatch_browser_transform(artifact, options)?;
161 let TransformResult { artifact, warnings } = output;
162 let artifact_info = artifact_info(&artifact);
163 let suggested_extension = output_extension(artifact.media_type).to_string();
164
165 Ok(WasmTransformResponse {
166 bytes: artifact.bytes,
167 artifact: artifact_info,
168 warnings: warnings
169 .into_iter()
170 .map(|warning| warning.to_string())
171 .collect(),
172 suggested_extension,
173 })
174}
175
176fn sniff_browser_artifact(
177 input_bytes: Vec<u8>,
178 declared_media_type: Option<&str>,
179) -> Result<Artifact, TransformError> {
180 let declared_media_type = declared_media_type
181 .map(|value| parse_media_type(value, "declaredMediaType"))
182 .transpose()?;
183
184 sniff_artifact(RawArtifact::new(input_bytes, declared_media_type))
185}
186
187fn parse_wasm_options(options: WasmTransformOptions) -> Result<TransformOptions, TransformError> {
188 let (strip_metadata, preserve_exif) =
189 crate::core::resolve_metadata_flags(None, options.keep_metadata, options.preserve_exif)?;
190
191 let fit = parse_optional_enum(options.fit, "fit")?;
192 let position = parse_optional_enum(options.position, "position")?;
193 let format = options
194 .format
195 .as_deref()
196 .map(|value| parse_media_type(value, "format"))
197 .transpose()?;
198 let background = options
199 .background
200 .as_deref()
201 .map(|value| {
202 Rgba8::from_hex(value).map_err(|reason| {
203 TransformError::InvalidOptions(format!("background is invalid: {reason}"))
204 })
205 })
206 .transpose()?;
207
208 Ok(TransformOptions {
209 width: options.width,
210 height: options.height,
211 fit,
212 position,
213 format,
214 quality: options.quality,
215 background,
216 rotate: parse_rotation(options.rotate)?,
217 auto_orient: options.auto_orient.unwrap_or(true),
218 strip_metadata,
219 preserve_exif,
220 deadline: None,
221 })
222}
223
224fn parse_optional_enum<T>(value: Option<String>, field: &str) -> Result<Option<T>, TransformError>
225where
226 T: FromStr<Err = String>,
227{
228 value
229 .map(|value| {
230 T::from_str(&value).map_err(|reason| {
231 TransformError::InvalidOptions(format!("{field} is invalid: {reason}"))
232 })
233 })
234 .transpose()
235}
236
237fn parse_media_type(value: &str, field: &str) -> Result<MediaType, TransformError> {
238 MediaType::from_str(value)
239 .map_err(|reason| TransformError::InvalidOptions(format!("{field} is invalid: {reason}")))
240}
241
242fn parse_rotation(value: Option<u16>) -> Result<Rotation, TransformError> {
243 match value.unwrap_or(0) {
244 0 => Ok(Rotation::Deg0),
245 90 => Ok(Rotation::Deg90),
246 180 => Ok(Rotation::Deg180),
247 270 => Ok(Rotation::Deg270),
248 other => Err(TransformError::InvalidOptions(format!(
249 "rotate is invalid: unsupported rotation `{other}`"
250 ))),
251 }
252}
253
254fn dispatch_browser_transform(
255 artifact: Artifact,
256 options: TransformOptions,
257) -> Result<TransformResult, TransformError> {
258 if artifact.media_type != MediaType::Svg && options.format == Some(MediaType::Svg) {
259 return Err(TransformError::UnsupportedOutputMediaType(MediaType::Svg));
260 }
261
262 if artifact.media_type == MediaType::Svg {
263 #[cfg(feature = "svg")]
264 {
265 return transform_svg(TransformRequest::new(artifact, options));
266 }
267 #[cfg(not(feature = "svg"))]
268 {
269 let _ = options;
270 return Err(TransformError::CapabilityMissing(
271 "SVG processing is not enabled in this build".to_string(),
272 ));
273 }
274 }
275
276 transform_raster(TransformRequest::new(artifact, options))
277}
278
279fn artifact_info(artifact: &Artifact) -> WasmArtifactInfo {
280 WasmArtifactInfo {
281 media_type: artifact.media_type.as_name().to_string(),
282 mime_type: artifact.media_type.as_mime().to_string(),
283 width: artifact.metadata.width,
284 height: artifact.metadata.height,
285 frame_count: artifact.metadata.frame_count,
286 has_alpha: artifact.metadata.has_alpha,
287 }
288}
289
290fn output_extension(media_type: MediaType) -> &'static str {
291 match media_type {
292 MediaType::Jpeg => "jpg",
293 MediaType::Png => "png",
294 MediaType::Webp => "webp",
295 MediaType::Avif => "avif",
296 MediaType::Svg => "svg",
297 MediaType::Bmp => "bmp",
298 }
299}
300
301#[cfg(feature = "wasm")]
302fn error_kind(error: &TransformError) -> &'static str {
303 match error {
304 TransformError::InvalidInput(_) => "invalidInput",
305 TransformError::InvalidOptions(_) => "invalidOptions",
306 TransformError::UnsupportedInputMediaType(_) => "unsupportedInputMediaType",
307 TransformError::UnsupportedOutputMediaType(_) => "unsupportedOutputMediaType",
308 TransformError::DecodeFailed(_) => "decodeFailed",
309 TransformError::EncodeFailed(_) => "encodeFailed",
310 TransformError::CapabilityMissing(_) => "capabilityMissing",
311 TransformError::LimitExceeded(_) => "limitExceeded",
312 }
313}
314
315#[cfg(feature = "wasm")]
316fn serialize_json<T: Serialize>(value: &T) -> Result<String, JsValue> {
317 serde_json::to_string(value)
318 .map_err(|error| JsValue::from_str(&format!("failed to serialize WASM response: {error}")))
319}
320
321#[cfg(feature = "wasm")]
322fn transform_error_to_js(error: TransformError) -> JsValue {
323 let payload = WasmErrorPayload {
324 kind: error_kind(&error),
325 message: error.to_string(),
326 };
327
328 serialize_json(&payload)
329 .map(JsValue::from)
330 .unwrap_or_else(|_| JsValue::from_str(&format!("{}: {}", payload.kind, payload.message)))
331}
332
333#[cfg(feature = "wasm")]
338#[wasm_bindgen]
339pub struct WasmTransformOutput {
340 bytes: Vec<u8>,
341 response_json: String,
342}
343
344#[cfg(feature = "wasm")]
345#[wasm_bindgen]
346impl WasmTransformOutput {
347 #[wasm_bindgen(getter)]
349 pub fn bytes(&self) -> Vec<u8> {
350 self.bytes.clone()
351 }
352
353 #[wasm_bindgen(js_name = responseJson, getter)]
355 pub fn response_json(&self) -> String {
356 self.response_json.clone()
357 }
358}
359
360#[cfg(feature = "wasm")]
362#[wasm_bindgen(js_name = getCapabilitiesJson)]
363pub fn get_capabilities_json() -> Result<String, JsValue> {
364 serialize_json(&browser_capabilities())
365}
366
367#[cfg(feature = "wasm")]
372#[wasm_bindgen(js_name = inspectImageJson)]
373pub fn inspect_image_json(
374 input_bytes: &[u8],
375 declared_media_type: Option<String>,
376) -> Result<String, JsValue> {
377 let response = inspect_browser_artifact(input_bytes.to_vec(), declared_media_type.as_deref())
378 .map_err(transform_error_to_js)?;
379
380 serialize_json(&response)
381}
382
383#[cfg(feature = "wasm")]
389#[wasm_bindgen(js_name = transformImage)]
390pub fn transform_image(
391 input_bytes: &[u8],
392 declared_media_type: Option<String>,
393 options_json: &str,
394) -> Result<WasmTransformOutput, JsValue> {
395 let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
396 transform_error_to_js(TransformError::InvalidOptions(format!(
397 "failed to parse transform options: {error}"
398 )))
399 })?;
400 let response = transform_browser_artifact(
401 input_bytes.to_vec(),
402 declared_media_type.as_deref(),
403 options,
404 )
405 .map_err(transform_error_to_js)?;
406 let response_json = serialize_json(&response)?;
407
408 Ok(WasmTransformOutput {
409 bytes: response.bytes,
410 response_json,
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use image::codecs::png::PngEncoder;
418 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
419
420 fn png_bytes(width: u32, height: u32) -> Vec<u8> {
421 let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
422 let mut bytes = Vec::new();
423 PngEncoder::new(&mut bytes)
424 .write_image(&image, width, height, ColorType::Rgba8.into())
425 .expect("encode png");
426 bytes
427 }
428
429 #[test]
430 fn browser_capabilities_reflect_compile_time_features() {
431 let capabilities = browser_capabilities();
432
433 assert_eq!(capabilities.svg, cfg!(feature = "svg"));
434 assert_eq!(capabilities.webp_lossy, cfg!(feature = "webp-lossy"));
435 }
436
437 #[test]
438 fn inspect_browser_artifact_reports_png_metadata() {
439 let response =
440 inspect_browser_artifact(png_bytes(4, 3), Some("png")).expect("inspect png artifact");
441
442 assert_eq!(response.artifact.media_type, "png");
443 assert_eq!(response.artifact.mime_type, "image/png");
444 assert_eq!(response.artifact.width, Some(4));
445 assert_eq!(response.artifact.height, Some(3));
446 assert_eq!(response.artifact.has_alpha, Some(true));
447 }
448
449 #[test]
450 fn transform_browser_artifact_converts_png_to_jpeg() {
451 let response = transform_browser_artifact(
452 png_bytes(4, 3),
453 Some("png"),
454 WasmTransformOptions {
455 format: Some("jpeg".to_string()),
456 width: Some(2),
457 ..WasmTransformOptions::default()
458 },
459 )
460 .expect("transform png to jpeg");
461
462 assert_eq!(response.artifact.media_type, "jpeg");
463 assert_eq!(response.artifact.mime_type, "image/jpeg");
464 assert_eq!(response.artifact.width, Some(2));
465 assert_eq!(response.artifact.height, Some(2));
466 assert_eq!(response.suggested_extension, "jpg");
467 assert!(response.bytes.starts_with(&[0xFF, 0xD8]));
468 }
469
470 #[test]
471 fn parse_wasm_options_rejects_conflicting_metadata_flags() {
472 let error = parse_wasm_options(WasmTransformOptions {
473 keep_metadata: Some(true),
474 preserve_exif: Some(true),
475 ..WasmTransformOptions::default()
476 })
477 .expect_err("conflicting metadata flags should fail");
478
479 assert_eq!(
480 error,
481 TransformError::InvalidOptions(
482 "keepMetadata and preserveExif cannot both be true".to_string()
483 )
484 );
485 }
486
487 #[test]
488 fn raster_input_cannot_request_svg_output() {
489 let error = transform_browser_artifact(
490 png_bytes(4, 3),
491 Some("png"),
492 WasmTransformOptions {
493 format: Some("svg".to_string()),
494 ..WasmTransformOptions::default()
495 },
496 )
497 .expect_err("raster input should not produce svg output");
498
499 assert_eq!(
500 error,
501 TransformError::UnsupportedOutputMediaType(MediaType::Svg)
502 );
503 }
504}