From 1be958cff97640c923c49e97461f5bd29b1dae2c Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Tue, 29 Sep 2026 10:36:33 +0000 Subject: [PATCH] fix(transform): keep transparency when choosing and encoding output formats --- README.md | 1 + src/modules/proxy/dto/params.rs | 49 ++++++ src/modules/transform/ops/alpha.rs | 110 ++++++++++++ src/modules/transform/ops/best_format.rs | 215 +++++++++++++++++++++-- src/modules/transform/ops/encode.rs | 51 +++++- src/modules/transform/ops/mod.rs | 1 + src/modules/transform/pipeline.rs | 103 ++++++++++- 7 files changed, 514 insertions(+), 16 deletions(-) create mode 100644 src/modules/transform/ops/alpha.rs diff --git a/README.md b/README.md index 863fd22..5a8f217 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Query params take precedence when both styles are combined. | `contrast` | -100 to 100 | Contrast adjustment | | `blur` | float (sigma) | Gaussian blur | | `wm` | URL | Watermark image URL | +| `bg` | hex color, e.g. `ffffff` (default) | Background used to flatten transparency when the output is JPEG, which cannot store it. Formats with alpha (PNG, WebP, AVIF, JXL) keep the transparency. `best` never picks JPEG for a transparent image while another allowed format can store it | | `seek` | `5.0`, `0.5r`, `auto` (default: `0`) | Video seek: absolute seconds, relative ratio, or auto (middle frame) | | `gif_anim` | `all`, `N`, `N-M`, `-N` | Animated GIF: output all frames, apply transforms starting at frame N, to frame range N-M, or to last N frames | | `gif_af` | `true`, `1` | GIF all-frames: output all frames, not just the `gif_anim` range; style transforms apply only to frames in range, geometric transforms (resize, rotate, flip) apply to all frames | diff --git a/src/modules/proxy/dto/params.rs b/src/modules/proxy/dto/params.rs index cc1e8bf..49e6f52 100644 --- a/src/modules/proxy/dto/params.rs +++ b/src/modules/proxy/dto/params.rs @@ -58,6 +58,9 @@ pub struct TransformParams { pub wm_scale: Option, /// Text watermark content (URL-safe string). Takes lower priority than `wm`. pub wmt: Option, + /// Background hex color without `#`, e.g. `ffffff` (default), used to flatten + /// transparency when the output format cannot store it (JPEG). + pub bg: Option, /// Text watermark hex color without `#`, e.g. `ff0000` (default `000000`). pub wmt_color: Option, /// Text watermark font size in pixels (default 24). @@ -209,6 +212,9 @@ impl TransformParams { if other.wmt.is_some() { self.wmt = other.wmt; } + if other.bg.is_some() { + self.bg = other.bg; + } if other.wmt_color.is_some() { self.wmt_color = other.wmt_color; } @@ -305,6 +311,9 @@ impl TransformParams { if let Some(v) = &self.wmt { parts.push(format!("wmt={v}")); } + if let Some(v) = &self.bg { + parts.push(format!("bg={v}")); + } if let Some(v) = &self.wmt_color { parts.push(format!("wmt_color={v}")); } @@ -498,6 +507,11 @@ fn parse_options(opts: &str) -> Result { p.wmt = Some(val.to_string()); continue; } + // bg:ffffff + if let Some(val) = token.strip_prefix("bg:") { + p.bg = Some(val.to_string()); + continue; + } // wmt_color:ff0000 if let Some(val) = token.strip_prefix("wmt_color:") { p.wmt_color = Some(val.to_string()); @@ -681,6 +695,7 @@ fn parse_options(opts: &str) -> Result { ) } "wmt" => p.wmt = Some(val.to_string()), + "bg" => p.bg = Some(val.to_string()), "wmt_color" => p.wmt_color = Some(val.to_string()), "wmt_size" => { p.wmt_size = Some( @@ -789,6 +804,9 @@ pub fn from_query( if let Some(v) = query.get("wmt") { p.wmt = Some(v.clone()); } + if let Some(v) = query.get("bg") { + p.bg = Some(v.clone()); + } if let Some(v) = query.get("wmt_color") { p.wmt_color = Some(v.clone()); } @@ -1910,4 +1928,35 @@ mod tests { "got: {result:?}" ); } + + #[test] + fn test_bg_parsed_from_path_token() { + let (p, _) = + TransformParams::from_path("300x200,bg:ff8800/https://example.com/img.png").unwrap(); + assert_eq!(p.bg, Some("ff8800".to_string())); + } + + #[test] + fn test_bg_in_canonical_string() { + let params = TransformParams { + bg: Some("ff8800".to_string()), + ..Default::default() + }; + // The cache key must differ per background. + assert!( + params + .canonical_string("https://example.com/i.png") + .contains("bg=ff8800") + ); + } + + #[test] + fn test_bg_counts_as_no_transform_by_itself() { + // bg only changes how transparency is flattened, so alone it is not a transform. + let params = TransformParams { + bg: Some("ff8800".to_string()), + ..Default::default() + }; + assert!(!params.has_transforms()); + } } diff --git a/src/modules/transform/ops/alpha.rs b/src/modules/transform/ops/alpha.rs new file mode 100644 index 0000000..97bac62 --- /dev/null +++ b/src/modules/transform/ops/alpha.rs @@ -0,0 +1,110 @@ +use crate::common::errors::ProxyError; +use image::{DynamicImage, Rgb, RgbImage}; + +/// Background used to flatten transparency when a format cannot store it. +pub const DEFAULT_BACKGROUND: [u8; 3] = [255, 255, 255]; + +/// Returns true when at least one pixel is not fully opaque. +/// +/// A color type with an alpha channel is not enough: most RGBA PNGs are fully +/// opaque and can safely go to formats without alpha. +pub fn has_transparency(img: &DynamicImage) -> bool { + if !img.color().has_alpha() { + return false; + } + img.to_rgba8().pixels().any(|p| p[3] < 255) +} + +/// Composites the image over a solid background and drops the alpha channel. +/// +/// Encoders for formats without alpha (JPEG) discard the channel, which exposes +/// the color stored under transparent pixels - usually black. Blending first +/// makes the result match what a viewer shows. +pub fn flatten(img: &DynamicImage, bg: [u8; 3]) -> DynamicImage { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + let mut out = RgbImage::new(w, h); + for (x, y, p) in rgba.enumerate_pixels() { + let a = p[3] as u32; + let blend = |fg: u8, bg: u8| ((fg as u32 * a + bg as u32 * (255 - a) + 127) / 255) as u8; + out.put_pixel( + x, + y, + Rgb([blend(p[0], bg[0]), blend(p[1], bg[1]), blend(p[2], bg[2])]), + ); + } + DynamicImage::ImageRgb8(out) +} + +/// Parses a `RRGGBB` hex color, with or without a leading `#`. +pub fn parse_background(hex: &str) -> Result<[u8; 3], ProxyError> { + let invalid = || ProxyError::InvalidParams(format!("invalid bg: {hex}")); + let digits = hex.trim_start_matches('#'); + if digits.len() != 6 || !digits.is_ascii() { + return Err(invalid()); + } + let channel = + |range: std::ops::Range| u8::from_str_radix(&digits[range], 16).map_err(|_| invalid()); + Ok([channel(0..2)?, channel(2..4)?, channel(4..6)?]) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ImageBuffer, Rgba}; + + fn rgba(pixels: &[[u8; 4]]) -> DynamicImage { + DynamicImage::ImageRgba8(ImageBuffer::from_fn(pixels.len() as u32, 1, |x, _| { + Rgba(pixels[x as usize]) + })) + } + + #[test] + fn test_opaque_rgba_has_no_transparency() { + assert!(!has_transparency(&rgba(&[[1, 2, 3, 255], [4, 5, 6, 255]]))); + } + + #[test] + fn test_rgb_has_no_transparency() { + assert!(!has_transparency(&DynamicImage::new_rgb8(2, 2))); + } + + #[test] + fn test_any_translucent_pixel_counts() { + assert!(has_transparency(&rgba(&[[1, 2, 3, 255], [0, 0, 0, 0]]))); + assert!(has_transparency(&rgba(&[[1, 2, 3, 254]]))); + } + + #[test] + fn test_flatten_transparent_pixel_takes_background() { + let out = flatten(&rgba(&[[0, 0, 0, 0]]), [10, 20, 30]).to_rgb8(); + assert_eq!(out.get_pixel(0, 0).0, [10, 20, 30]); + } + + #[test] + fn test_flatten_opaque_pixel_is_unchanged() { + let out = flatten(&rgba(&[[200, 100, 50, 255]]), [255, 255, 255]).to_rgb8(); + assert_eq!(out.get_pixel(0, 0).0, [200, 100, 50]); + } + + #[test] + fn test_flatten_blends_partial_alpha() { + // Half black over white lands mid-way. + let out = flatten(&rgba(&[[0, 0, 0, 128]]), [255, 255, 255]).to_rgb8(); + let v = out.get_pixel(0, 0)[0]; + assert!((126..=128).contains(&v), "got {v}"); + } + + #[test] + fn test_parse_background() { + assert_eq!(parse_background("ff8000").unwrap(), [255, 128, 0]); + assert_eq!(parse_background("#FF8000").unwrap(), [255, 128, 0]); + } + + #[test] + fn test_parse_background_rejects_bad_input() { + for bad in ["", "fff", "gggggg", "ff80001", "ééé"] { + assert!(parse_background(bad).is_err(), "{bad}"); + } + } +} diff --git a/src/modules/transform/ops/best_format.rs b/src/modules/transform/ops/best_format.rs index 581b871..ce599f8 100644 --- a/src/modules/transform/ops/best_format.rs +++ b/src/modules/transform/ops/best_format.rs @@ -1,7 +1,7 @@ use crate::common::config::BestFormatConfig; use crate::common::config::DisallowedOutput; use crate::common::errors::ProxyError; -use crate::modules::transform::ops::encode; +use crate::modules::transform::ops::{alpha, encode}; use image::DynamicImage; use std::collections::HashSet; use std::time::Instant; @@ -9,8 +9,17 @@ use std::time::Instant; /// Computes the percentage of edge pixels using a Sobel scan on a downsampled luma image. /// Returns a value in [0, 100]. pub fn edge_density(img: &DynamicImage) -> f64 { - let thumb = img.thumbnail(200, 200); - let luma = thumb.to_luma8(); + // Transparent pixels usually hold black, which would read as a strong edge + // against any content. Measure what a viewer sees instead. Flattening comes + // before shrinking, since resampling straight alpha bleeds that black into + // the edges of the content. + let luma = if alpha::has_transparency(img) { + alpha::flatten(img, alpha::DEFAULT_BACKGROUND) + .thumbnail(200, 200) + .to_luma8() + } else { + img.thumbnail(200, 200).to_luma8() + }; let (w, h) = luma.dimensions(); if w < 3 || h < 3 { return 0.0; @@ -47,6 +56,11 @@ fn format_to_disallow_token(fmt: &str) -> Option { } } +/// Returns true for formats that cannot store transparency. +fn drops_alpha(fmt: &str) -> bool { + matches!(fmt, "jpeg" | "bmp") +} + /// Returns true for lossless formats that should be skipped for complex (high-edge) images. fn is_lossless(fmt: &str) -> bool { matches!(fmt, "png" | "bmp" | "tiff") @@ -54,6 +68,8 @@ fn is_lossless(fmt: &str) -> bool { /// Selects the best output format for `img` by: /// 1. Measuring edge density to classify complexity. +/// Images with transparency never pick a format that drops it, unless every +/// allowed format does, in which case it is flattened onto `background`. /// 2. If resolution exceeds `cfg.max_resolution`, picking the first allowed format (fast path). /// 3. Otherwise encoding all preferred candidate formats and returning the smallest. pub fn select_best_format( @@ -62,6 +78,7 @@ pub fn select_best_format( cfg: &BestFormatConfig, output_disallow: &HashSet, src_content_type: &str, + background: [u8; 3], ) -> Result<(Vec, String), ProxyError> { let t0 = Instant::now(); let mpx = img.width() as f64 * img.height() as f64 / 1_000_000.0; @@ -78,14 +95,30 @@ pub fn select_best_format( "best_format: edge density" ); + let has_alpha = alpha::has_transparency(img); + let build_candidates = |fmts: &[String]| -> Vec { - fmts + let allowed: Vec = fmts .iter() .filter(|fmt| !(is_complex && is_lossless(fmt))) .filter(|fmt| fmt.as_str() != "gif" || src_is_gif) .filter(|fmt| format_to_disallow_token(fmt).is_none_or(|t| !output_disallow.contains(&t))) .cloned() - .collect() + .collect(); + + if !has_alpha { + return allowed; + } + let keeps_alpha: Vec = allowed + .iter() + .filter(|fmt| !drops_alpha(fmt)) + .cloned() + .collect(); + if keeps_alpha.is_empty() { + allowed + } else { + keeps_alpha + } }; // Fast path: image too large to trial-encode - pick the first allowed preferred format @@ -99,7 +132,7 @@ pub fn select_best_format( elapsed_ms = t0.elapsed().as_millis(), "best_format: fast path selected" ); - return encode::encode(img.clone(), &allowed[0], quality); + return encode::encode_with_background(img.clone(), &allowed[0], quality, background); } // Full path: encode all preferred candidates, pick smallest @@ -113,7 +146,7 @@ pub fn select_best_format( let mut best: Option<(Vec, String)> = None; for fmt in &candidates { let t_enc = Instant::now(); - match encode::encode(img.clone(), fmt, quality) { + match encode::encode_with_background(img.clone(), fmt, quality, background) { Ok(result) => { let is_smaller = best.as_ref().is_none_or(|(b, _)| result.0.len() < b.len()); tracing::debug!( @@ -189,7 +222,15 @@ mod tests { fn test_select_best_format_returns_bytes() { let img = solid_image(10); let cfg = BestFormatConfig::default(); - let (bytes, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/png").unwrap(); + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert!(!bytes.is_empty()); assert!(ct.starts_with("image/")); } @@ -198,7 +239,15 @@ mod tests { fn test_select_best_format_low_complexity_may_choose_png() { let img = solid_image(50); let cfg = BestFormatConfig::default(); - let (_, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/png").unwrap(); + let (_, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert!(ct.starts_with("image/")); } @@ -210,7 +259,14 @@ mod tests { disallow.insert(DisallowedOutput::Jpeg); disallow.insert(DisallowedOutput::Webp); disallow.insert(DisallowedOutput::Png); - let result = select_best_format(&img, 85, &cfg, &disallow, "image/png"); + let result = select_best_format( + &img, + 85, + &cfg, + &disallow, + "image/png", + alpha::DEFAULT_BACKGROUND, + ); assert!( matches!(result, Err(ProxyError::TransformDisabled(_))), "all candidates disallowed must return TransformDisabled" @@ -225,7 +281,15 @@ mod tests { ..BestFormatConfig::default() }; // Non-GIF source: gif must be excluded, jpeg wins - let (_, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/png").unwrap(); + let (_, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert_eq!(ct, "image/jpeg"); } @@ -237,7 +301,15 @@ mod tests { ..BestFormatConfig::default() }; // GIF source: gif is allowed as a candidate - let (bytes, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/gif").unwrap(); + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/gif", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert!(!bytes.is_empty()); assert!(ct == "image/jpeg" || ct == "image/gif"); } @@ -249,7 +321,15 @@ mod tests { preferred_formats: vec!["avif".to_string(), "jxl".to_string()], ..BestFormatConfig::default() }; - let (bytes, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/png").unwrap(); + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert!(!bytes.is_empty()); assert!(ct == "image/avif" || ct == "image/jxl"); } @@ -261,8 +341,115 @@ mod tests { max_resolution: Some(0.0), ..BestFormatConfig::default() }; - let (bytes, ct) = select_best_format(&img, 85, &cfg, &HashSet::new(), "image/png").unwrap(); + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); assert!(!bytes.is_empty()); assert!(ct.starts_with("image/")); } + + /// A logo-like image: opaque colored shape on a fully transparent area. + fn transparent_logo(size: u32) -> DynamicImage { + use image::Rgba; + DynamicImage::ImageRgba8(ImageBuffer::from_fn(size, size, |x, y| { + let inside = x > size / 4 && x < size * 3 / 4 && y > size / 4 && y < size * 3 / 4; + if inside { + Rgba([0u8, 160, 255, 255]) + } else { + Rgba([0u8, 0, 0, 0]) + } + })) + } + + #[test] + fn test_transparent_image_never_picks_jpeg() { + let img = transparent_logo(64); + let cfg = BestFormatConfig::default(); // jpeg, webp, png + let (_, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); + assert_ne!(ct, "image/jpeg"); + } + + #[test] + fn test_transparent_image_result_keeps_alpha() { + let img = transparent_logo(64); + let cfg = BestFormatConfig::default(); + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); + let decoded = image::load_from_memory(&bytes).unwrap(); + assert!(alpha::has_transparency(&decoded), "{ct} lost the alpha"); + } + + #[test] + fn test_transparent_image_flattened_when_only_jpeg_allowed() { + let img = transparent_logo(64); + let cfg = BestFormatConfig { + preferred_formats: vec!["jpeg".to_string()], + ..BestFormatConfig::default() + }; + let (bytes, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + [255, 255, 255], + ) + .unwrap(); + assert_eq!(ct, "image/jpeg"); + // The corner was transparent: it must be the background, not black. + let corner = image::load_from_memory(&bytes).unwrap().to_rgb8(); + assert!(corner.get_pixel(0, 0).0.iter().all(|c| *c > 240)); + } + + #[test] + fn test_opaque_rgba_still_may_pick_jpeg() { + use image::Rgba; + let img = DynamicImage::ImageRgba8(ImageBuffer::from_fn(32, 32, |x, y| { + Rgba([(x * 8) as u8, (y * 8) as u8, 128, 255]) + })); + let cfg = BestFormatConfig { + preferred_formats: vec!["jpeg".to_string()], + ..BestFormatConfig::default() + }; + let (_, ct) = select_best_format( + &img, + 85, + &cfg, + &HashSet::new(), + "image/png", + alpha::DEFAULT_BACKGROUND, + ) + .unwrap(); + assert_eq!(ct, "image/jpeg"); + } + + #[test] + fn test_transparency_does_not_count_as_edges() { + // Same visible content: the black under transparent pixels must not add edges. + let logo = transparent_logo(64); + let flat = alpha::flatten(&logo, alpha::DEFAULT_BACKGROUND); + let diff = (edge_density(&logo) - edge_density(&flat)).abs(); + assert!(diff < 0.01, "density differs by {diff}"); + } } diff --git a/src/modules/transform/ops/encode.rs b/src/modules/transform/ops/encode.rs index e94aced..e3c6c5f 100644 --- a/src/modules/transform/ops/encode.rs +++ b/src/modules/transform/ops/encode.rs @@ -1,13 +1,26 @@ use crate::common::errors::ProxyError; +use crate::modules::transform::ops::alpha; use bytemuck::cast_slice; use image::{DynamicImage, ImageFormat}; use std::io::Cursor; -#[tracing::instrument(skip(img))] +/// Encodes with the default background for formats that cannot store alpha. pub fn encode( img: DynamicImage, format: &str, quality: u32, +) -> Result<(Vec, String), ProxyError> { + encode_with_background(img, format, quality, alpha::DEFAULT_BACKGROUND) +} + +/// Encodes the image. Transparency is flattened onto `background` for JPEG, +/// which cannot store it; every other format keeps the alpha channel. +#[tracing::instrument(skip(img))] +pub fn encode_with_background( + img: DynamicImage, + format: &str, + quality: u32, + background: [u8; 3], ) -> Result<(Vec, String), ProxyError> { let (fmt, content_type) = match format { "webp" => (ImageFormat::WebP, "image/webp"), @@ -63,6 +76,11 @@ pub fn encode( let mut buf = Cursor::new(Vec::new()); if fmt == ImageFormat::Jpeg { + let img = if alpha::has_transparency(&img) { + alpha::flatten(&img, background) + } else { + img + }; let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100) as u8); tracing::trace!("Encoding image to JPEG format with quality {}", quality); @@ -149,4 +167,35 @@ mod tests { assert_eq!(ct, "image/jxl"); assert!(!bytes.is_empty()); } + + fn transparent_pixel_image() -> DynamicImage { + DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel( + 16, + 16, + image::Rgba([0u8, 0, 0, 0]), + )) + } + + #[test] + fn test_encode_jpeg_flattens_transparency_to_white() { + let (bytes, _) = encode(transparent_pixel_image(), "jpeg", 90).unwrap(); + let px = image::load_from_memory(&bytes).unwrap().to_rgb8(); + assert!(px.get_pixel(8, 8).0.iter().all(|c| *c > 240)); + } + + #[test] + fn test_encode_jpeg_uses_custom_background() { + let (bytes, _) = + encode_with_background(transparent_pixel_image(), "jpeg", 90, [255, 0, 0]).unwrap(); + let px = image::load_from_memory(&bytes).unwrap().to_rgb8(); + let p = px.get_pixel(8, 8); + assert!(p[0] > 200 && p[1] < 60 && p[2] < 60, "{p:?}"); + } + + #[test] + fn test_encode_png_keeps_transparency() { + let (bytes, _) = encode(transparent_pixel_image(), "png", 90).unwrap(); + let decoded = image::load_from_memory(&bytes).unwrap(); + assert!(alpha::has_transparency(&decoded)); + } } diff --git a/src/modules/transform/ops/mod.rs b/src/modules/transform/ops/mod.rs index d1976ce..1ff3d87 100644 --- a/src/modules/transform/ops/mod.rs +++ b/src/modules/transform/ops/mod.rs @@ -1,3 +1,4 @@ +pub mod alpha; pub mod best_format; pub mod blur; pub mod color; diff --git a/src/modules/transform/pipeline.rs b/src/modules/transform/pipeline.rs index 04443ae..350bec2 100644 --- a/src/modules/transform/pipeline.rs +++ b/src/modules/transform/pipeline.rs @@ -178,6 +178,12 @@ pub async fn run_pipeline( return Ok((src_bytes, resolved_ct)); } + // Background for flattening transparency; validated before any work is done. + let background = match params.bg.as_deref() { + Some(hex) => ops::alpha::parse_background(hex)?, + None => ops::alpha::DEFAULT_BACKGROUND, + }; + // 3. Fetch watermark bytes if needed (async, before spawn_blocking) // Disallow check for wmt (text watermark has no fetch, checked here before blocking) if params.wmt.is_some() @@ -302,9 +308,10 @@ pub async fn run_pipeline( &best_format_cfg_clone, &output_disallow_clone, &resolved_ct_clone, + background, ) } else { - ops::encode::encode(img, effective_fmt_clone.as_str(), quality) + ops::encode::encode_with_background(img, effective_fmt_clone.as_str(), quality, background) }?; // Allow-skips: if best format selected the same format as source, and no non-format transforms @@ -867,4 +874,98 @@ mod tests { .unwrap(); assert!(!out.is_empty()); } + + /// A 16x16 PNG: an opaque blue square in the middle, transparent black around it. + fn transparent_logo_png() -> Vec { + use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba}; + let img = DynamicImage::ImageRgba8(ImageBuffer::from_fn(16, 16, |x, y| { + if (4..12).contains(&x) && (4..12).contains(&y) { + Rgba([0u8, 120, 255, 255]) + } else { + Rgba([0u8, 0, 0, 0]) + } + })); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, ImageFormat::Png).unwrap(); + buf.into_inner() + } + + async fn run_transparent(params: TransformParams) -> Result<(Vec, String), ProxyError> { + run_pipeline( + params, + transparent_logo_png(), + Some("image/png".to_string()), + test_fetcher(), + &std::collections::HashSet::new(), + &std::collections::HashSet::new(), + &best_format_cfg_default(), + ) + .await + } + + #[tokio::test] + async fn test_default_jpeg_output_flattens_transparency_to_white() { + let params = TransformParams { + w: Some(32), + ..Default::default() + }; + let (out, ct) = run_transparent(params).await.unwrap(); + assert_eq!(ct, "image/jpeg"); + let px = image::load_from_memory(&out).unwrap().to_rgb8(); + assert!( + px.get_pixel(0, 0).0.iter().all(|c| *c > 240), + "corner is not white" + ); + } + + #[tokio::test] + async fn test_bg_param_sets_flatten_color() { + let params = TransformParams { + w: Some(32), + bg: Some("ff0000".to_string()), + ..Default::default() + }; + let (out, _) = run_transparent(params).await.unwrap(); + let px = image::load_from_memory(&out).unwrap().to_rgb8(); + let p = px.get_pixel(0, 0); + assert!(p[0] > 200 && p[1] < 60 && p[2] < 60, "{p:?}"); + } + + #[tokio::test] + async fn test_invalid_bg_is_rejected() { + let params = TransformParams { + w: Some(32), + bg: Some("nothex".to_string()), + ..Default::default() + }; + assert!(matches!( + run_transparent(params).await, + Err(ProxyError::InvalidParams(_)) + )); + } + + #[tokio::test] + async fn test_png_output_keeps_transparency() { + let params = TransformParams { + w: Some(32), + format: Some("png".to_string()), + ..Default::default() + }; + let (out, ct) = run_transparent(params).await.unwrap(); + assert_eq!(ct, "image/png"); + let px = image::load_from_memory(&out).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(0, 0)[3], 0); + } + + #[tokio::test] + async fn test_best_format_keeps_transparency() { + let params = TransformParams { + format: Some("best".to_string()), + ..Default::default() + }; + let (out, ct) = run_transparent(params).await.unwrap(); + assert_ne!(ct, "image/jpeg"); + let px = image::load_from_memory(&out).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(0, 0)[3], 0, "{ct} lost the alpha"); + } }