Skip to main content

truss/adapters/
wasm.rs

1//! Browser and WebAssembly adapter support.
2//!
3//! This module keeps the browser-facing contract separate from the core Rust types so the
4//! GitHub Pages demo can exchange simple JSON-like objects with JavaScript while still
5//! reusing the shared transformation pipeline.
6
7use 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/// Browser-facing transform options accepted by the WASM adapter.
21///
22/// The fields intentionally use strings for enum-like values so JavaScript callers do not
23/// need to understand the Rust enum layout. The adapter validates and converts these fields
24/// before calling the shared Core transformation pipeline.
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct WasmTransformOptions {
28    /// The requested output width in pixels.
29    pub width: Option<u32>,
30    /// The requested output height in pixels.
31    pub height: Option<u32>,
32    /// The resize fit mode (`contain`, `cover`, `fill`, or `inside`).
33    pub fit: Option<String>,
34    /// The crop anchor (`center`, `top-left`, and so on).
35    pub position: Option<String>,
36    /// The requested output format (`jpeg`, `png`, `webp`, `avif`, `bmp`, `tiff`, or `svg`).
37    pub format: Option<String>,
38    /// The requested lossy quality from 1 to 100.
39    pub quality: Option<u8>,
40    /// Optional background color as `RRGGBB` or `RRGGBBAA`.
41    pub background: Option<String>,
42    /// Optional clockwise rotation in degrees. Supported values are `0`, `90`, `180`, `270`.
43    pub rotate: Option<u16>,
44    /// Whether EXIF auto-orientation should run. Defaults to `true`.
45    pub auto_orient: Option<bool>,
46    /// Whether all supported metadata should be retained when possible.
47    pub keep_metadata: Option<bool>,
48    /// Whether only EXIF metadata should be retained.
49    pub preserve_exif: Option<bool>,
50    /// Explicit crop region as `x,y,w,h`.
51    pub crop: Option<String>,
52    /// Gaussian blur sigma (0.1–100.0).
53    pub blur: Option<f32>,
54    /// Sharpen sigma (0.1–100.0).
55    pub sharpen: Option<f32>,
56}
57
58/// Build-time capabilities exposed by the WASM adapter.
59///
60/// The GitHub Pages UI uses this to disable controls for features that are intentionally
61/// absent from the browser build, such as SVG processing or lossy WebP encoding.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct WasmCapabilities {
65    /// Whether SVG input and SVG output processing are available in this build.
66    pub svg: bool,
67    /// Whether quality-controlled lossy WebP encoding is available in this build.
68    pub webp_lossy: bool,
69}
70
71/// Serializable metadata about an inspected or transformed artifact.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct WasmArtifactInfo {
75    /// Canonical media type name such as `png` or `jpeg`.
76    pub media_type: String,
77    /// MIME type string such as `image/png`.
78    pub mime_type: String,
79    /// Rendered width in pixels when known.
80    pub width: Option<u32>,
81    /// Rendered height in pixels when known.
82    pub height: Option<u32>,
83    /// Frame count for the artifact.
84    pub frame_count: u32,
85    /// Whether the artifact contains alpha when known.
86    pub has_alpha: Option<bool>,
87}
88
89/// Response payload returned by [`inspect_browser_artifact`].
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct WasmInspectResponse {
93    /// Inspected metadata for the supplied artifact.
94    pub artifact: WasmArtifactInfo,
95}
96
97/// Response payload returned by [`transform_browser_artifact`].
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct WasmTransformResponse {
101    /// Transformed output bytes. JavaScript receives this as a `Uint8Array`.
102    pub bytes: Vec<u8>,
103    /// Metadata describing the transformed artifact.
104    pub artifact: WasmArtifactInfo,
105    /// Non-fatal warnings emitted by the transform pipeline.
106    pub warnings: Vec<String>,
107    /// Suggested output extension derived from the output media type.
108    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
119/// Returns the compile-time capabilities of the current WASM build.
120pub fn browser_capabilities() -> WasmCapabilities {
121    WasmCapabilities {
122        svg: cfg!(feature = "svg"),
123        webp_lossy: cfg!(feature = "webp-lossy"),
124    }
125}
126
127/// Inspects browser-provided bytes and returns metadata suitable for JavaScript callers.
128///
129/// `declared_media_type` may be omitted. When present, it is validated against the detected
130/// signature in the same way as the CLI and HTTP server adapters.
131///
132/// # Errors
133///
134/// Returns [`TransformError::InvalidInput`] when the declared media type conflicts with the
135/// detected bytes, [`TransformError::UnsupportedInputMediaType`] when the bytes are not a
136/// supported image format, and [`TransformError::DecodeFailed`] when the image structure is
137/// malformed.
138pub 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
149/// Transforms browser-provided bytes using JavaScript-friendly transform options.
150///
151/// This adapter intentionally excludes runtime-specific features such as local filesystem
152/// paths, server-side URL fetches, and secret-backed authentication. It only accepts raw
153/// input bytes and explicit transform options supplied by the browser application.
154///
155/// # Errors
156///
157/// Returns the same validation and execution errors as the shared transformation pipeline,
158/// plus [`TransformError::CapabilityMissing`] when a requested browser feature was compiled out
159/// of the current build, such as SVG processing or lossy WebP encoding.
160pub 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/// Browser-facing watermark options accepted by the WASM adapter.
350#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase", deny_unknown_fields)]
352pub struct WasmWatermarkOptions {
353    /// Watermark placement position (e.g. `bottom-right`, `center`).
354    pub position: Option<String>,
355    /// Watermark opacity (1–100). Default: 50.
356    pub opacity: Option<u8>,
357    /// Margin in pixels from the nearest edge. Default: 10.
358    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
396/// Transforms browser-provided bytes with an optional watermark overlay.
397pub 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/// Browser-facing transform output returned by [`transform_image`].
432///
433/// JavaScript callers receive the transformed bytes separately from the JSON metadata so the
434/// output can be downloaded or previewed without reparsing large byte arrays through JSON.
435#[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    /// Returns the transformed output bytes.
446    #[wasm_bindgen(getter)]
447    pub fn bytes(&self) -> Vec<u8> {
448        self.bytes.clone()
449    }
450
451    /// Returns JSON metadata describing the transformed output.
452    #[wasm_bindgen(js_name = responseJson, getter)]
453    pub fn response_json(&self) -> String {
454        self.response_json.clone()
455    }
456}
457
458/// Returns build-time capabilities to JavaScript callers as a JSON string.
459#[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/// Inspects image bytes supplied by JavaScript and returns structured metadata as JSON.
466///
467/// The returned object contains the canonical media type, MIME type, dimensions, frame count,
468/// and alpha information when available.
469#[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/// Transforms image bytes supplied by JavaScript and returns output bytes plus metadata.
482///
483/// `options_json` must match the JSON shape of [`WasmTransformOptions`]. On success, the
484/// returned object contains output bytes plus a JSON metadata payload describing the artifact,
485/// warnings, and suggested file extension for download flows.
486#[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/// Transforms image bytes with a watermark overlay supplied by JavaScript.
513///
514/// `watermark_bytes` must contain valid raster image bytes (not SVG).
515/// `watermark_options_json` must match the JSON shape of [`WasmWatermarkOptions`].
516#[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        // Minimal valid SVG
646        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        // Verify the output is a valid PNG (magic bytes)
755        assert!(response.bytes.starts_with(&[0x89, b'P', b'N', b'G']));
756    }
757}