//! Alpha mask rasterisation helpers for the zeno-based rendering pipeline. //! //! This module provides: //! - `rasterise_to_alpha` — rasterises zeno path commands to a u8 alpha mask //! - `apply_colour` — converts an alpha mask + colour into premultiplied RGBA //! - `apply_stackblur_alpha` — single-channel stackblur (4x less work than RGBA) #[cfg(not(feature = "std"))] use alloc::vec::Vec; #[cfg(feature = "std")] use std::vec::Vec; /// Result of alpha rasterisation: the mask data and its placement info. pub struct AlphaMask { /// Alpha mask data, one byte per pixel. pub data: Vec, /// Width of the mask in pixels. pub width: u32, /// Height of the mask in pixels. pub height: u32, /// X offset from origin (the left edge of the bounding box). pub offset_x: f32, /// Y offset from origin (the top edge of the bounding box). pub offset_y: f32, } /// Rasterise zeno path commands to an alpha mask via `zeno::Mask`. /// /// Returns `None` if the path is empty or produces a zero-size mask. /// The `padding` parameter expands the rasterisation bounds (used for blur). /// /// The `style` parameter controls whether this is a fill or stroke rasterisation. pub fn rasterise_to_alpha( commands: &[zeno::Command], transform: Option, padding: f32, scratch: &mut zeno::Scratch, style: zeno::Style<'_>, ) -> Option { if commands.is_empty() { return None; } // Compute bounds to determine offset needed for padding let bounds = scratch.bounds(commands, style, transform); if bounds.is_empty() { return None; } // Floor min, ceil max to get pixel-aligned bounds let min_x = (bounds.min.x - padding).floor(); let min_y = (bounds.min.y - padding).floor(); let max_x = (bounds.max.x + padding).ceil(); let max_y = (bounds.max.y + padding).ceil(); let width = (max_x - min_x) as u32; let height = (max_y - min_y) as u32; if width == 0 || height == 0 { return None; } // The offset vector translates path coordinates so that the bounding box // top-left maps to pixel (0, 0) in the output buffer. let offset = zeno::Vector::new(-min_x, -min_y); let mut mask_builder = zeno::Mask::with_scratch(commands, scratch); mask_builder .style(style) .format(zeno::Format::Alpha) .offset(offset) .size(width, height); if let Some(t) = transform { mask_builder.transform(Some(t)); } let (data, _placement) = mask_builder.render(); // Verify the buffer is the expected size let expected = (width as usize) * (height as usize); if data.len() != expected { return None; } Some(AlphaMask { data, width, height, offset_x: min_x, offset_y: min_y, }) } /// Apply colour to an alpha mask, producing premultiplied RGBA output. /// /// The `colour` parameter is `[R, G, B, A]` in straight (non-premultiplied) form. /// Output is premultiplied: each channel is scaled by the effective alpha /// (mask coverage * colour alpha). /// /// Uses full-precision integer arithmetic to avoid intermediate quantisation /// errors: /// - `fa = coverage * ca` ranges 0..65025 /// - `final_alpha = (fa + 127) / 255` — standard rounding division /// - `channel = (c * fa + 32512) / 65025` — division with half-bias rounding pub fn apply_colour(alpha: &[u8], colour: [u8; 4]) -> Vec { let mut rgba = Vec::with_capacity(alpha.len() * 4); let cr = colour[0] as u32; let cg = colour[1] as u32; let cb = colour[2] as u32; let ca = colour[3] as u32; for &a in alpha { let fa = a as u32 * ca; // 0..65025 let final_alpha = (fa + 127) / 255; let r = (cr * fa + 32512) / 65025; let g = (cg * fa + 32512) / 65025; let b = (cb * fa + 32512) / 65025; rgba.extend_from_slice(&[r as u8, g as u8, b as u8, final_alpha as u8]); } rgba } /// Single-channel accumulator for stackblur-iter. /// /// Wraps a single i64 channel with the arithmetic operations that /// `StackBlurrable` requires. #[derive(Clone, Default)] struct AlphaAccum(i64); impl core::ops::Add for AlphaAccum { type Output = Self; fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } } impl core::ops::AddAssign for AlphaAccum { fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; } } impl core::ops::SubAssign for AlphaAccum { fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; } } impl core::ops::Mul for AlphaAccum { type Output = Self; fn mul(self, rhs: usize) -> Self { Self(self.0 * rhs as i64) } } impl core::ops::Div for AlphaAccum { type Output = Self; fn div(self, rhs: usize) -> Self { Self(self.0 / rhs as i64) } } /// Apply stackblur to a single-channel alpha mask buffer. /// /// This is 4x less work than the RGBA stackblur since there is only one /// channel per pixel instead of four. Uses the same `stackblur-iter` /// algorithm as the RGBA variant. /// /// `width` and `height` are the mask dimensions; `data` must be exactly /// `width * height` bytes. pub fn apply_stackblur_alpha(data: &mut [u8], width: usize, height: usize, radius: f32) { if radius <= 0.0 || width == 0 || height == 0 { return; } // Convert ASS \blur radius to Gaussian sigma (same formula as libass) let sigma = radius * (2.0 / (256.0_f32.ln()).sqrt()); // Map sigma to stackblur radius (same multiplier as RGBA variant) let blur_radius = (sigma * 2.0).ceil() as usize; if blur_radius == 0 { return; } // Wrap &mut [u8] as imgref for stackblur-iter. // Each pixel is a single u8, so we use [u8; 1] as the pixel type. let pixel_slice: &mut [[u8; 1]] = bytemuck::cast_slice_mut(data); let mut img = imgref::ImgRefMut::new(pixel_slice, width, height); stackblur_iter::blur( &mut img, blur_radius, |px: &[u8; 1]| -> AlphaAccum { AlphaAccum(px[0] as i64) }, |acc: AlphaAccum| -> [u8; 1] { [acc.0.clamp(0, 255) as u8] }, ); } /// Apply edge blur (ASS `\be` tag) to a single-channel alpha mask. /// /// Applies a 3x3 weighted kernel `[[1,2,1],[2,4,2],[1,2,1]]` (total weight 16) /// the specified number of `passes` times. pub fn apply_be_blur_alpha( data: &mut [u8], width: usize, height: usize, passes: u32, arena: &bumpalo::Bump, ) { if passes == 0 || width == 0 || height == 0 { return; } let kernel: [[u32; 3]; 3] = [[1, 2, 1], [2, 4, 2], [1, 2, 1]]; let mut temp = bumpalo::collections::Vec::from_iter_in( core::iter::repeat(0u8).take(width * height), arena, ); for _ in 0..passes { for y in 0..height { for x in 0..width { let mut acc: u32 = 0; for ky in 0..3usize { for kx in 0..3usize { let sy = (y as isize + ky as isize - 1).clamp(0, height as isize - 1) as usize; let sx = (x as isize + kx as isize - 1).clamp(0, width as isize - 1) as usize; acc += data[sy * width + sx] as u32 * kernel[ky][kx]; } } temp[y * width + x] = (acc / 16) as u8; } } data.copy_from_slice(&temp); } } #[cfg(test)] mod tests { use alloc::vec; use super::*; #[test] fn apply_colour_opaque_white() { // Fully opaque white applied to a mask with value 128 should give // approximately half-intensity white. let alpha = [128u8]; let rgba = apply_colour(&alpha, [255, 255, 255, 255]); assert_eq!(rgba.len(), 4); // 128 * 255 = 32640, (32640 + 127) / 255 = 128 assert_eq!(rgba[3], 128); // alpha // (255 * 32640 + 32512) / 65025 = (8323200 + 32512) / 65025 = 128 assert_eq!(rgba[0], 128); // R assert_eq!(rgba[1], 128); // G assert_eq!(rgba[2], 128); // B } #[test] fn apply_colour_transparent() { let alpha = [255u8]; let rgba = apply_colour(&alpha, [255, 0, 0, 0]); // Zero alpha colour should produce zero everything assert_eq!(rgba, &[0, 0, 0, 0]); } #[test] fn apply_colour_zero_coverage() { let alpha = [0u8]; let rgba = apply_colour(&alpha, [255, 128, 64, 255]); assert_eq!(rgba, &[0, 0, 0, 0]); } #[test] fn apply_colour_full_opaque() { let alpha = [255u8]; let rgba = apply_colour(&alpha, [200, 100, 50, 255]); // Full coverage, full alpha: premultiplied = straight assert_eq!(rgba[0], 200); assert_eq!(rgba[1], 100); assert_eq!(rgba[2], 50); assert_eq!(rgba[3], 255); } #[test] fn apply_colour_half_alpha() { // Half colour alpha, full coverage let alpha = [255u8]; let rgba = apply_colour(&alpha, [200, 100, 50, 128]); // fa = 255 * 128 = 32640 // final_alpha = (32640 + 127) / 255 = 128 assert_eq!(rgba[3], 128); // r = (200 * 32640 + 32512) / 65025 ≈ 100 let expected_r = (200u32 * 32640 + 32512) / 65025; assert_eq!(rgba[0], expected_r as u8); } #[test] fn stackblur_alpha_no_crash_on_small_buffer() { let mut data = vec![255u8; 4 * 4]; apply_stackblur_alpha(&mut data, 4, 4, 1.0); // Just verify it doesn't panic; exact values depend on stackblur internals } #[test] fn stackblur_alpha_zero_radius_is_noop() { let original = vec![100u8, 200, 50, 0]; let mut data = original.clone(); apply_stackblur_alpha(&mut data, 2, 2, 0.0); assert_eq!(data, original); } #[test] fn be_blur_alpha_single_pass() { let arena = bumpalo::Bump::new(); // 3x3 mask with center pixel set let mut data = vec![0u8; 9]; data[4] = 255; // center apply_be_blur_alpha(&mut data, 3, 3, 1, &arena); // Center weight = 4/16 * 255 = 63.75 -> 63 assert_eq!(data[4], 63); // Edge weights should be non-zero assert!(data[1] > 0); // top center: weight 2 assert!(data[3] > 0); // left center: weight 2 } #[test] fn rasterise_to_alpha_empty_commands() { let mut scratch = zeno::Scratch::new(); let result = rasterise_to_alpha(&[], None, 0.0, &mut scratch, zeno::Fill::NonZero.into()); assert!(result.is_none()); } #[test] fn rasterise_to_alpha_simple_rect() { let mut scratch = zeno::Scratch::new(); let commands = vec![ zeno::Command::MoveTo(zeno::Vector::new(10.0, 10.0)), zeno::Command::LineTo(zeno::Vector::new(50.0, 10.0)), zeno::Command::LineTo(zeno::Vector::new(50.0, 30.0)), zeno::Command::LineTo(zeno::Vector::new(10.0, 30.0)), zeno::Command::Close, ]; let mask = rasterise_to_alpha( &commands, None, 0.0, &mut scratch, zeno::Fill::NonZero.into(), ); let mask = mask.expect("should produce a mask"); assert!(mask.width > 0); assert!(mask.height > 0); assert_eq!(mask.data.len(), (mask.width * mask.height) as usize); // The mask should have some non-zero pixels (the rectangle interior) assert!(mask.data.iter().any(|&a| a > 0)); } }