use super::burn_in::BurnInColor;
#[derive(Debug, Clone)]
pub struct TextBitmap {
pub bitmap: Vec<u8>,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone)]
pub struct SoftShadowConfig {
pub offset_x: i32,
pub offset_y: i32,
pub blur_radius: f32,
pub color: BurnInColor,
}
impl Default for SoftShadowConfig {
fn default() -> Self {
Self {
offset_x: 2,
offset_y: 2,
blur_radius: 3.0,
color: BurnInColor::black_with_alpha(180),
}
}
}
impl SoftShadowConfig {
#[must_use]
pub fn web() -> Self {
Self::default()
}
#[must_use]
pub fn broadcast() -> Self {
Self {
offset_x: 3,
offset_y: 3,
blur_radius: 5.0,
color: BurnInColor::black_with_alpha(220),
}
}
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn gaussian_blur_alpha(bitmap: &[u8], width: u32, height: u32, radius: f32) -> Vec<u8> {
if width == 0 || height == 0 || bitmap.is_empty() {
return bitmap.to_vec();
}
let sigma = radius.max(0.5);
let kernel_radius = (sigma * 3.0).ceil() as usize;
let kernel_size = 2 * kernel_radius + 1;
let mut kernel = Vec::with_capacity(kernel_size);
let mut kernel_sum = 0.0_f32;
for i in 0..kernel_size {
let x = i as f32 - kernel_radius as f32;
let val = (-0.5 * (x / sigma) * (x / sigma)).exp();
kernel.push(val);
kernel_sum += val;
}
for k in &mut kernel {
*k /= kernel_sum;
}
let w = width as usize;
let h = height as usize;
let mut tmp = vec![0.0_f32; w * h];
let mut out = vec![0u8; w * h];
for row in 0..h {
for col in 0..w {
let mut acc = 0.0_f32;
for (ki, &kval) in kernel.iter().enumerate() {
let src_col = col as isize + ki as isize - kernel_radius as isize;
let src_col = src_col.clamp(0, w as isize - 1) as usize;
acc += bitmap[row * w + src_col] as f32 * kval;
}
tmp[row * w + col] = acc;
}
}
for row in 0..h {
for col in 0..w {
let mut acc = 0.0_f32;
for (ki, &kval) in kernel.iter().enumerate() {
let src_row = row as isize + ki as isize - kernel_radius as isize;
let src_row = src_row.clamp(0, h as isize - 1) as usize;
acc += tmp[src_row * w + col] * kval;
}
out[row * w + col] = acc.clamp(0.0, 255.0) as u8;
}
}
out
}
#[allow(
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn render_soft_shadow_rgba(
buffer: &mut [u8],
frame_w: u32,
frame_h: u32,
text_bitmap: &TextBitmap,
text_color: BurnInColor,
text_x: u32,
text_y: u32,
shadow: &SoftShadowConfig,
) -> Result<(), String> {
let expected = (frame_w as usize) * (frame_h as usize) * 4;
if buffer.len() < expected {
return Err(format!("Buffer too small: {} < {expected}", buffer.len()));
}
if text_bitmap.width == 0 || text_bitmap.height == 0 {
return Ok(());
}
let blurred = gaussian_blur_alpha(
&text_bitmap.bitmap,
text_bitmap.width,
text_bitmap.height,
shadow.blur_radius,
);
let shadow_alpha_base = f32::from(shadow.color.a) / 255.0;
let sh_w = text_bitmap.width;
let sh_h = text_bitmap.height;
for gy in 0..sh_h {
for gx in 0..sh_w {
let sx = text_x as i64 + gx as i64 + shadow.offset_x as i64;
let sy = text_y as i64 + gy as i64 + shadow.offset_y as i64;
if sx < 0 || sy < 0 || sx >= frame_w as i64 || sy >= frame_h as i64 {
continue;
}
let glyph_idx = (gy * sh_w + gx) as usize;
let glyph_alpha = blurred.get(glyph_idx).copied().unwrap_or(0);
if glyph_alpha == 0 {
continue;
}
let alpha_f = glyph_alpha as f32 / 255.0 * shadow_alpha_base;
let inv_alpha = 1.0 - alpha_f;
let idx = (sy as u32 * frame_w + sx as u32) as usize * 4;
if idx + 3 >= buffer.len() {
continue;
}
buffer[idx] = (shadow.color.r as f32 * alpha_f + buffer[idx] as f32 * inv_alpha) as u8;
buffer[idx + 1] =
(shadow.color.g as f32 * alpha_f + buffer[idx + 1] as f32 * inv_alpha) as u8;
buffer[idx + 2] =
(shadow.color.b as f32 * alpha_f + buffer[idx + 2] as f32 * inv_alpha) as u8;
buffer[idx + 3] =
buffer[idx + 3].saturating_add(((255.0 - buffer[idx + 3] as f32) * alpha_f) as u8);
}
}
let text_alpha_base = f32::from(text_color.a) / 255.0;
for gy in 0..text_bitmap.height {
for gx in 0..text_bitmap.width {
let px = text_x + gx;
let py = text_y + gy;
if px >= frame_w || py >= frame_h {
continue;
}
let glyph_idx = (gy * text_bitmap.width + gx) as usize;
let glyph_alpha = text_bitmap.bitmap.get(glyph_idx).copied().unwrap_or(0);
if glyph_alpha == 0 {
continue;
}
let alpha_f = glyph_alpha as f32 / 255.0 * text_alpha_base;
let inv_alpha = 1.0 - alpha_f;
let idx = (py * frame_w + px) as usize * 4;
if idx + 3 >= buffer.len() {
continue;
}
buffer[idx] = (text_color.r as f32 * alpha_f + buffer[idx] as f32 * inv_alpha) as u8;
buffer[idx + 1] =
(text_color.g as f32 * alpha_f + buffer[idx + 1] as f32 * inv_alpha) as u8;
buffer[idx + 2] =
(text_color.b as f32 * alpha_f + buffer[idx + 2] as f32 * inv_alpha) as u8;
buffer[idx + 3] =
buffer[idx + 3].saturating_add(((255.0 - buffer[idx + 3] as f32) * alpha_f) as u8);
}
}
Ok(())
}
#[cfg(test)]
mod soft_shadow_tests {
use super::*;
#[test]
fn test_gaussian_blur_preserves_size() {
let bm = vec![255u8; 100]; let blurred = gaussian_blur_alpha(&bm, 10, 10, 2.0);
assert_eq!(blurred.len(), 100);
}
#[test]
fn test_gaussian_blur_spreads_signal() {
let mut bm = vec![0u8; 25]; bm[12] = 255; let blurred = gaussian_blur_alpha(&bm, 5, 5, 1.5);
assert!(blurred[0] > 0, "corner should gain signal from blur");
assert!(
blurred[12] > blurred[0],
"center should be brighter than corner"
);
}
#[test]
fn test_gaussian_blur_zero_radius_is_near_identity() {
let bm = vec![128u8, 64, 200, 10, 0];
let blurred = gaussian_blur_alpha(&bm, 5, 1, 0.5);
assert_eq!(blurred.len(), 5);
for (orig, blur) in bm.iter().zip(blurred.iter()) {
let diff = (*orig as i32 - *blur as i32).unsigned_abs();
assert!(diff < 80, "blur too aggressive: orig={orig} blur={blur}");
}
}
#[test]
fn test_gaussian_blur_empty_returns_empty() {
let blurred = gaussian_blur_alpha(&[], 0, 0, 2.0);
assert!(blurred.is_empty());
}
#[test]
fn test_soft_shadow_config_defaults() {
let cfg = SoftShadowConfig::default();
assert_eq!(cfg.offset_x, 2);
assert_eq!(cfg.offset_y, 2);
assert!((cfg.blur_radius - 3.0).abs() < f32::EPSILON);
assert_eq!(cfg.color.a, 180);
}
#[test]
fn test_soft_shadow_config_broadcast() {
let cfg = SoftShadowConfig::broadcast();
assert_eq!(cfg.offset_x, 3);
assert!((cfg.blur_radius - 5.0).abs() < f32::EPSILON);
assert!(cfg.color.a > 180);
}
#[test]
fn test_render_soft_shadow_basic() {
let w = 64u32;
let h = 32u32;
let mut buf = vec![0u8; (w * h * 4) as usize];
let bm = TextBitmap {
bitmap: vec![200u8; 64],
width: 8,
height: 8,
};
let shadow = SoftShadowConfig::default();
let result =
render_soft_shadow_rgba(&mut buf, w, h, &bm, BurnInColor::white(), 10, 10, &shadow);
assert!(result.is_ok());
assert!(buf.iter().any(|&b| b > 0));
}
#[test]
fn test_render_soft_shadow_buffer_too_small() {
let mut buf = vec![0u8; 10];
let bm = TextBitmap {
bitmap: vec![255; 4],
width: 2,
height: 2,
};
let shadow = SoftShadowConfig::default();
let result =
render_soft_shadow_rgba(&mut buf, 100, 100, &bm, BurnInColor::white(), 0, 0, &shadow);
assert!(result.is_err());
}
#[test]
fn test_render_soft_shadow_shadow_precedes_text() {
let w = 64u32;
let h = 64u32;
let mut buf = vec![0u8; (w * h * 4) as usize];
let bm = TextBitmap {
bitmap: vec![255u8; 100],
width: 10,
height: 10,
};
let shadow = SoftShadowConfig {
offset_x: 5,
offset_y: 5,
blur_radius: 1.0,
color: BurnInColor::new(0, 0, 0, 200),
};
render_soft_shadow_rgba(
&mut buf,
w,
h,
&bm,
BurnInColor::new(255, 255, 255, 0), 0,
0,
&shadow,
)
.expect("render");
assert!(buf.iter().any(|&b| b > 0), "shadow should paint pixels");
}
}