1use crate::{
8 Artifact, CropRegion, MediaType, Position, RawArtifact, Rgba8, Rotation, TransformError,
9 TransformOptions, TransformRequest, TransformResult, WatermarkInput, sniff_artifact,
10 transform_raster,
11};
12use serde::{Deserialize, Serialize};
13use std::str::FromStr;
14
15#[cfg(feature = "svg")]
16use crate::transform_svg;
17#[cfg(feature = "wasm")]
18use wasm_bindgen::prelude::*;
19
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct WasmTransformOptions {
28 pub width: Option<u32>,
30 pub height: Option<u32>,
32 pub fit: Option<String>,
34 pub position: Option<String>,
36 pub format: Option<String>,
38 pub quality: Option<u8>,
40 pub background: Option<String>,
42 pub rotate: Option<u16>,
44 pub auto_orient: Option<bool>,
46 pub keep_metadata: Option<bool>,
48 pub preserve_exif: Option<bool>,
50 pub crop: Option<String>,
52 pub blur: Option<f32>,
54 pub sharpen: Option<f32>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct WasmCapabilities {
65 pub svg: bool,
67 pub webp_lossy: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct WasmArtifactInfo {
75 pub media_type: String,
77 pub mime_type: String,
79 pub width: Option<u32>,
81 pub height: Option<u32>,
83 pub frame_count: u32,
85 pub has_alpha: Option<bool>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct WasmInspectResponse {
93 pub artifact: WasmArtifactInfo,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct WasmTransformResponse {
101 pub bytes: Vec<u8>,
103 pub artifact: WasmArtifactInfo,
105 pub warnings: Vec<String>,
107 pub suggested_extension: String,
109}
110
111#[cfg(feature = "wasm")]
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(rename_all = "camelCase")]
114struct WasmErrorPayload {
115 kind: &'static str,
116 message: String,
117}
118
119pub fn browser_capabilities() -> WasmCapabilities {
121 WasmCapabilities {
122 svg: cfg!(feature = "svg"),
123 webp_lossy: cfg!(feature = "webp-lossy"),
124 }
125}
126
127pub fn inspect_browser_artifact(
139 input_bytes: Vec<u8>,
140 declared_media_type: Option<&str>,
141) -> Result<WasmInspectResponse, TransformError> {
142 let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
143
144 Ok(WasmInspectResponse {
145 artifact: artifact_info(&artifact),
146 })
147}
148
149pub fn transform_browser_artifact(
161 input_bytes: Vec<u8>,
162 declared_media_type: Option<&str>,
163 options: WasmTransformOptions,
164) -> Result<WasmTransformResponse, TransformError> {
165 let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
166 let options = parse_wasm_options(options)?;
167 build_transform_response(artifact, options, None)
168}
169
170fn sniff_browser_artifact(
171 input_bytes: Vec<u8>,
172 declared_media_type: Option<&str>,
173) -> Result<Artifact, TransformError> {
174 let declared_media_type = declared_media_type
175 .map(|value| parse_media_type(value, "declaredMediaType"))
176 .transpose()?;
177
178 sniff_artifact(RawArtifact::new(input_bytes, declared_media_type))
179}
180
181fn parse_wasm_options(options: WasmTransformOptions) -> Result<TransformOptions, TransformError> {
182 let (strip_metadata, preserve_exif) =
183 crate::core::resolve_metadata_flags(None, options.keep_metadata, options.preserve_exif)?;
184
185 let fit = parse_optional_enum(options.fit, "fit")?;
186 let position = parse_optional_enum(options.position, "position")?;
187 let format = options
188 .format
189 .as_deref()
190 .map(|value| parse_media_type(value, "format"))
191 .transpose()?;
192 let background = options
193 .background
194 .as_deref()
195 .map(|value| {
196 Rgba8::from_hex(value).map_err(|reason| {
197 TransformError::InvalidOptions(format!("background is invalid: {reason}"))
198 })
199 })
200 .transpose()?;
201
202 let crop = options
203 .crop
204 .as_deref()
205 .map(|v| {
206 CropRegion::from_str(v).map_err(|reason| {
207 TransformError::InvalidOptions(format!("crop is invalid: {reason}"))
208 })
209 })
210 .transpose()?;
211
212 Ok(TransformOptions {
213 width: options.width,
214 height: options.height,
215 fit,
216 position,
217 format,
218 quality: options.quality,
219 background,
220 rotate: parse_rotation(options.rotate)?,
221 auto_orient: options.auto_orient.unwrap_or(true),
222 strip_metadata,
223 preserve_exif,
224 crop,
225 blur: options.blur,
226 sharpen: options.sharpen,
227 deadline: None,
228 })
229}
230
231fn parse_optional_enum<T>(value: Option<String>, field: &str) -> Result<Option<T>, TransformError>
232where
233 T: FromStr<Err = String>,
234{
235 value
236 .map(|value| {
237 T::from_str(&value).map_err(|reason| {
238 TransformError::InvalidOptions(format!("{field} is invalid: {reason}"))
239 })
240 })
241 .transpose()
242}
243
244fn parse_media_type(value: &str, field: &str) -> Result<MediaType, TransformError> {
245 MediaType::from_str(value)
246 .map_err(|reason| TransformError::InvalidOptions(format!("{field} is invalid: {reason}")))
247}
248
249fn parse_rotation(value: Option<u16>) -> Result<Rotation, TransformError> {
250 match value.unwrap_or(0) {
251 0 => Ok(Rotation::Deg0),
252 90 => Ok(Rotation::Deg90),
253 180 => Ok(Rotation::Deg180),
254 270 => Ok(Rotation::Deg270),
255 other => Err(TransformError::InvalidOptions(format!(
256 "rotate is invalid: unsupported rotation `{other}`"
257 ))),
258 }
259}
260
261fn dispatch_browser_transform_with_watermark(
262 artifact: Artifact,
263 options: TransformOptions,
264 watermark: Option<WatermarkInput>,
265) -> Result<TransformResult, TransformError> {
266 if artifact.media_type != MediaType::Svg && options.format == Some(MediaType::Svg) {
267 return Err(TransformError::UnsupportedOutputMediaType(MediaType::Svg));
268 }
269
270 if artifact.media_type == MediaType::Svg {
271 if watermark.is_some() {
272 return Err(TransformError::InvalidOptions(
273 "watermark is not supported for SVG inputs".to_string(),
274 ));
275 }
276 #[cfg(feature = "svg")]
277 {
278 return transform_svg(TransformRequest::new(artifact, options));
279 }
280 #[cfg(not(feature = "svg"))]
281 {
282 let _ = options;
283 return Err(TransformError::CapabilityMissing(
284 "SVG processing is not enabled in this build".to_string(),
285 ));
286 }
287 }
288
289 let mut request = TransformRequest::new(artifact, options);
290 request.watermark = watermark;
291 transform_raster(request)
292}
293
294fn artifact_info(artifact: &Artifact) -> WasmArtifactInfo {
295 WasmArtifactInfo {
296 media_type: artifact.media_type.as_name().to_string(),
297 mime_type: artifact.media_type.as_mime().to_string(),
298 width: artifact.metadata.width,
299 height: artifact.metadata.height,
300 frame_count: artifact.metadata.frame_count,
301 has_alpha: artifact.metadata.has_alpha,
302 }
303}
304
305fn output_extension(media_type: MediaType) -> &'static str {
306 match media_type {
307 MediaType::Jpeg => "jpg",
308 MediaType::Png => "png",
309 MediaType::Webp => "webp",
310 MediaType::Avif => "avif",
311 MediaType::Svg => "svg",
312 MediaType::Bmp => "bmp",
313 MediaType::Tiff => "tiff",
314 }
315}
316
317#[cfg(feature = "wasm")]
318fn error_kind(error: &TransformError) -> &'static str {
319 match error {
320 TransformError::InvalidInput(_) => "invalidInput",
321 TransformError::InvalidOptions(_) => "invalidOptions",
322 TransformError::UnsupportedInputMediaType(_) => "unsupportedInputMediaType",
323 TransformError::UnsupportedOutputMediaType(_) => "unsupportedOutputMediaType",
324 TransformError::DecodeFailed(_) => "decodeFailed",
325 TransformError::EncodeFailed(_) => "encodeFailed",
326 TransformError::CapabilityMissing(_) => "capabilityMissing",
327 TransformError::LimitExceeded(_) => "limitExceeded",
328 }
329}
330
331#[cfg(feature = "wasm")]
332fn serialize_json<T: Serialize>(value: &T) -> Result<String, JsValue> {
333 serde_json::to_string(value)
334 .map_err(|error| JsValue::from_str(&format!("failed to serialize WASM response: {error}")))
335}
336
337#[cfg(feature = "wasm")]
338fn transform_error_to_js(error: TransformError) -> JsValue {
339 let payload = WasmErrorPayload {
340 kind: error_kind(&error),
341 message: error.to_string(),
342 };
343
344 serialize_json(&payload)
345 .map(JsValue::from)
346 .unwrap_or_else(|_| JsValue::from_str(&format!("{}: {}", payload.kind, payload.message)))
347}
348
349#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase", deny_unknown_fields)]
352pub struct WasmWatermarkOptions {
353 pub position: Option<String>,
355 pub opacity: Option<u8>,
357 pub margin: Option<u32>,
359}
360
361fn resolve_wasm_watermark(
362 watermark_bytes: Vec<u8>,
363 watermark_options: WasmWatermarkOptions,
364) -> Result<WatermarkInput, TransformError> {
365 let artifact = sniff_artifact(RawArtifact::new(watermark_bytes, None))?;
366 if !artifact.media_type.is_raster() {
367 return Err(TransformError::InvalidOptions(
368 "watermark image must be a raster format, not SVG".to_string(),
369 ));
370 }
371 let position = watermark_options
372 .position
373 .map(|v| {
374 Position::from_str(&v).map_err(|reason| {
375 TransformError::InvalidOptions(format!("watermark position is invalid: {reason}"))
376 })
377 })
378 .transpose()?
379 .unwrap_or(Position::BottomRight);
380 let opacity = watermark_options.opacity.unwrap_or(50);
381 if opacity == 0 || opacity > 100 {
382 return Err(TransformError::InvalidOptions(
383 "watermark opacity must be between 1 and 100".to_string(),
384 ));
385 }
386 let margin = watermark_options.margin.unwrap_or(10);
387
388 Ok(WatermarkInput {
389 image: artifact,
390 position,
391 opacity,
392 margin,
393 })
394}
395
396pub fn transform_browser_artifact_with_watermark(
398 input_bytes: Vec<u8>,
399 declared_media_type: Option<&str>,
400 options: WasmTransformOptions,
401 watermark_bytes: Vec<u8>,
402 watermark_options: WasmWatermarkOptions,
403) -> Result<WasmTransformResponse, TransformError> {
404 let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
405 let options = parse_wasm_options(options)?;
406 let watermark = resolve_wasm_watermark(watermark_bytes, watermark_options)?;
407 build_transform_response(artifact, options, Some(watermark))
408}
409
410fn build_transform_response(
411 artifact: Artifact,
412 options: TransformOptions,
413 watermark: Option<WatermarkInput>,
414) -> Result<WasmTransformResponse, TransformError> {
415 let output = dispatch_browser_transform_with_watermark(artifact, options, watermark)?;
416 let TransformResult { artifact, warnings } = output;
417 let artifact_info = artifact_info(&artifact);
418 let suggested_extension = output_extension(artifact.media_type).to_string();
419
420 Ok(WasmTransformResponse {
421 bytes: artifact.bytes,
422 artifact: artifact_info,
423 warnings: warnings
424 .into_iter()
425 .map(|warning| warning.to_string())
426 .collect(),
427 suggested_extension,
428 })
429}
430
431#[cfg(feature = "wasm")]
436#[wasm_bindgen]
437pub struct WasmTransformOutput {
438 bytes: Vec<u8>,
439 response_json: String,
440}
441
442#[cfg(feature = "wasm")]
443#[wasm_bindgen]
444impl WasmTransformOutput {
445 #[wasm_bindgen(getter)]
447 pub fn bytes(&self) -> Vec<u8> {
448 self.bytes.clone()
449 }
450
451 #[wasm_bindgen(js_name = responseJson, getter)]
453 pub fn response_json(&self) -> String {
454 self.response_json.clone()
455 }
456}
457
458#[cfg(feature = "wasm")]
460#[wasm_bindgen(js_name = getCapabilitiesJson)]
461pub fn get_capabilities_json() -> Result<String, JsValue> {
462 serialize_json(&browser_capabilities())
463}
464
465#[cfg(feature = "wasm")]
470#[wasm_bindgen(js_name = inspectImageJson)]
471pub fn inspect_image_json(
472 input_bytes: &[u8],
473 declared_media_type: Option<String>,
474) -> Result<String, JsValue> {
475 let response = inspect_browser_artifact(input_bytes.to_vec(), declared_media_type.as_deref())
476 .map_err(transform_error_to_js)?;
477
478 serialize_json(&response)
479}
480
481#[cfg(feature = "wasm")]
487#[wasm_bindgen(js_name = transformImage)]
488pub fn transform_image(
489 input_bytes: &[u8],
490 declared_media_type: Option<String>,
491 options_json: &str,
492) -> Result<WasmTransformOutput, JsValue> {
493 let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
494 transform_error_to_js(TransformError::InvalidOptions(format!(
495 "failed to parse transform options: {error}"
496 )))
497 })?;
498 let response = transform_browser_artifact(
499 input_bytes.to_vec(),
500 declared_media_type.as_deref(),
501 options,
502 )
503 .map_err(transform_error_to_js)?;
504 let response_json = serialize_json(&response)?;
505
506 Ok(WasmTransformOutput {
507 bytes: response.bytes,
508 response_json,
509 })
510}
511
512#[cfg(feature = "wasm")]
517#[wasm_bindgen(js_name = transformImageWithWatermark)]
518pub fn transform_image_with_watermark(
519 input_bytes: &[u8],
520 declared_media_type: Option<String>,
521 options_json: &str,
522 watermark_bytes: &[u8],
523 watermark_options_json: &str,
524) -> Result<WasmTransformOutput, JsValue> {
525 let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
526 transform_error_to_js(TransformError::InvalidOptions(format!(
527 "failed to parse transform options: {error}"
528 )))
529 })?;
530 let watermark_options = serde_json::from_str::<WasmWatermarkOptions>(watermark_options_json)
531 .map_err(|error| {
532 transform_error_to_js(TransformError::InvalidOptions(format!(
533 "failed to parse watermark options: {error}"
534 )))
535 })?;
536 let response = transform_browser_artifact_with_watermark(
537 input_bytes.to_vec(),
538 declared_media_type.as_deref(),
539 options,
540 watermark_bytes.to_vec(),
541 watermark_options,
542 )
543 .map_err(transform_error_to_js)?;
544 let response_json = serialize_json(&response)?;
545
546 Ok(WasmTransformOutput {
547 bytes: response.bytes,
548 response_json,
549 })
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use image::codecs::png::PngEncoder;
556 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
557
558 fn png_bytes(width: u32, height: u32) -> Vec<u8> {
559 let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
560 let mut bytes = Vec::new();
561 PngEncoder::new(&mut bytes)
562 .write_image(&image, width, height, ColorType::Rgba8.into())
563 .expect("encode png");
564 bytes
565 }
566
567 #[test]
568 fn browser_capabilities_reflect_compile_time_features() {
569 let capabilities = browser_capabilities();
570
571 assert_eq!(capabilities.svg, cfg!(feature = "svg"));
572 assert_eq!(capabilities.webp_lossy, cfg!(feature = "webp-lossy"));
573 }
574
575 #[test]
576 fn inspect_browser_artifact_reports_png_metadata() {
577 let response =
578 inspect_browser_artifact(png_bytes(4, 3), Some("png")).expect("inspect png artifact");
579
580 assert_eq!(response.artifact.media_type, "png");
581 assert_eq!(response.artifact.mime_type, "image/png");
582 assert_eq!(response.artifact.width, Some(4));
583 assert_eq!(response.artifact.height, Some(3));
584 assert_eq!(response.artifact.has_alpha, Some(true));
585 }
586
587 #[test]
588 fn transform_browser_artifact_converts_png_to_jpeg() {
589 let response = transform_browser_artifact(
590 png_bytes(4, 3),
591 Some("png"),
592 WasmTransformOptions {
593 format: Some("jpeg".to_string()),
594 width: Some(2),
595 ..WasmTransformOptions::default()
596 },
597 )
598 .expect("transform png to jpeg");
599
600 assert_eq!(response.artifact.media_type, "jpeg");
601 assert_eq!(response.artifact.mime_type, "image/jpeg");
602 assert_eq!(response.artifact.width, Some(2));
603 assert_eq!(response.artifact.height, Some(2));
604 assert_eq!(response.suggested_extension, "jpg");
605 assert!(response.bytes.starts_with(&[0xFF, 0xD8]));
606 }
607
608 #[test]
609 fn parse_wasm_options_rejects_conflicting_metadata_flags() {
610 let error = parse_wasm_options(WasmTransformOptions {
611 keep_metadata: Some(true),
612 preserve_exif: Some(true),
613 ..WasmTransformOptions::default()
614 })
615 .expect_err("conflicting metadata flags should fail");
616
617 assert_eq!(
618 error,
619 TransformError::InvalidOptions(
620 "keepMetadata and preserveExif cannot both be true".to_string()
621 )
622 );
623 }
624
625 #[test]
626 fn raster_input_cannot_request_svg_output() {
627 let error = transform_browser_artifact(
628 png_bytes(4, 3),
629 Some("png"),
630 WasmTransformOptions {
631 format: Some("svg".to_string()),
632 ..WasmTransformOptions::default()
633 },
634 )
635 .expect_err("raster input should not produce svg output");
636
637 assert_eq!(
638 error,
639 TransformError::UnsupportedOutputMediaType(MediaType::Svg)
640 );
641 }
642
643 #[test]
644 fn test_resolve_wasm_watermark_rejects_svg() {
645 let svg_bytes = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>".to_vec();
647 let error = resolve_wasm_watermark(svg_bytes, WasmWatermarkOptions::default())
648 .expect_err("SVG watermark should be rejected");
649
650 assert_eq!(
651 error,
652 TransformError::InvalidOptions(
653 "watermark image must be a raster format, not SVG".to_string()
654 )
655 );
656 }
657
658 #[test]
659 fn test_resolve_wasm_watermark_rejects_opacity_zero() {
660 let error = resolve_wasm_watermark(
661 png_bytes(2, 2),
662 WasmWatermarkOptions {
663 opacity: Some(0),
664 ..WasmWatermarkOptions::default()
665 },
666 )
667 .expect_err("opacity 0 should be rejected");
668
669 assert_eq!(
670 error,
671 TransformError::InvalidOptions(
672 "watermark opacity must be between 1 and 100".to_string()
673 )
674 );
675 }
676
677 #[test]
678 fn test_resolve_wasm_watermark_rejects_opacity_over_100() {
679 let error = resolve_wasm_watermark(
680 png_bytes(2, 2),
681 WasmWatermarkOptions {
682 opacity: Some(101),
683 ..WasmWatermarkOptions::default()
684 },
685 )
686 .expect_err("opacity 101 should be rejected");
687
688 assert_eq!(
689 error,
690 TransformError::InvalidOptions(
691 "watermark opacity must be between 1 and 100".to_string()
692 )
693 );
694 }
695
696 #[test]
697 fn test_resolve_wasm_watermark_defaults() {
698 let wm = resolve_wasm_watermark(png_bytes(2, 2), WasmWatermarkOptions::default())
699 .expect("valid watermark with defaults");
700
701 assert_eq!(wm.position, Position::BottomRight);
702 assert_eq!(wm.opacity, 50);
703 assert_eq!(wm.margin, 10);
704 }
705
706 #[test]
707 fn parse_wasm_options_parses_crop() {
708 let options = parse_wasm_options(WasmTransformOptions {
709 crop: Some("10,20,100,200".to_string()),
710 ..WasmTransformOptions::default()
711 })
712 .expect("valid crop should parse");
713
714 let crop = options.crop.expect("crop should be set");
715 assert_eq!(crop.x, 10);
716 assert_eq!(crop.y, 20);
717 assert_eq!(crop.width, 100);
718 assert_eq!(crop.height, 200);
719 }
720
721 #[test]
722 fn parse_wasm_options_rejects_invalid_crop() {
723 let error = parse_wasm_options(WasmTransformOptions {
724 crop: Some("bad".to_string()),
725 ..WasmTransformOptions::default()
726 })
727 .expect_err("invalid crop should fail");
728
729 assert!(
730 matches!(error, TransformError::InvalidOptions(ref msg) if msg.contains("crop")),
731 "unexpected error: {error}"
732 );
733 }
734
735 #[test]
736 fn test_transform_with_watermark_basic() {
737 let response = transform_browser_artifact_with_watermark(
738 png_bytes(16, 16),
739 None,
740 WasmTransformOptions::default(),
741 png_bytes(4, 4),
742 WasmWatermarkOptions {
743 position: Some("center".to_string()),
744 opacity: Some(80),
745 margin: Some(0),
746 },
747 )
748 .expect("transform with watermark should succeed");
749
750 assert_eq!(response.artifact.media_type, "png");
751 assert_eq!(response.artifact.width, Some(16));
752 assert_eq!(response.artifact.height, Some(16));
753 assert!(!response.bytes.is_empty());
754 assert!(response.bytes.starts_with(&[0x89, b'P', b'N', b'G']));
756 }
757}