use crate::geometry::LogicalRect;
use crate::style::Rgba;
#[derive(Clone)]
pub struct Framebuffer {
width: u32,
height: u32,
data: Vec<u8>,
}
impl std::fmt::Debug for Framebuffer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Framebuffer")
.field("width", &self.width)
.field("height", &self.height)
.finish_non_exhaustive()
}
}
impl Framebuffer {
pub fn new(width: u32, height: u32) -> Self {
let (w, h) = (width.max(1), height.max(1));
Framebuffer {
width: w,
height: h,
data: vec![0u8; (w as usize) * (h as usize) * 4],
}
}
pub fn reset(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1));
let len = (w as usize) * (h as usize) * 4;
if self.width == w && self.height == h {
self.data.fill(0);
} else {
self.width = w;
self.height = h;
self.data.clear();
self.data.resize(len, 0);
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn pixels(&self) -> &[u8] {
&self.data
}
pub fn pixels_mut(&mut self) -> &mut [u8] {
&mut self.data
}
pub fn fill(&mut self, color: Rgba) {
let a = color.a as u32;
let (r, g, b) = (
(color.r as u32 * a / 255) as u8,
(color.g as u32 * a / 255) as u8,
(color.b as u32 * a / 255) as u8,
);
for px in self.data.chunks_exact_mut(4) {
px[0] = r;
px[1] = g;
px[2] = b;
px[3] = color.a;
}
}
pub fn draw(&mut self, src: &Framebuffer, x: i32, y: i32) {
let (dw, dh) = (self.width as i32, self.height as i32);
let (sw, sh) = (src.width as i32, src.height as i32);
for row in 0..sh {
let dy = y + row;
if dy < 0 || dy >= dh {
continue;
}
for col in 0..sw {
let dx = x + col;
if dx < 0 || dx >= dw {
continue;
}
let s = ((row * sw + col) * 4) as usize;
let sa = src.data[s + 3];
if sa == 0 {
continue;
}
let d = ((dy * dw + dx) * 4) as usize;
let inv = 255 - sa as u32;
for c in 0..4 {
let out = src.data[s + c] as u32 + self.data[d + c] as u32 * inv / 255;
self.data[d + c] = out.min(255) as u8;
}
}
}
}
pub fn to_straight_rgba(&self) -> Vec<u8> {
let mut out = vec![0u8; self.data.len()];
for (dst, src) in out.chunks_exact_mut(4).zip(self.data.chunks_exact(4)) {
let a = src[3];
if a == 0 {
continue;
}
let a32 = a as u32;
dst[0] = (src[0] as u32 * 255 / a32).min(255) as u8;
dst[1] = (src[1] as u32 * 255 / a32).min(255) as u8;
dst[2] = (src[2] as u32 * 255 / a32).min(255) as u8;
dst[3] = a;
}
out
}
pub fn encode_png(&self) -> Vec<u8> {
let straight = self.to_straight_rgba();
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, self.width, self.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header");
writer.write_image_data(&straight).expect("png data");
}
out
}
}
pub(crate) fn encode_rgba_png(rgba: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
if width == 0 || height == 0 {
return None;
}
let expected = (width as usize)
.checked_mul(height as usize)?
.checked_mul(4)?;
if rgba.len() != expected {
return None;
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().ok()?;
writer.write_image_data(rgba).ok()?;
}
Some(out)
}
static SRGB_TO_LINEAR: std::sync::LazyLock<[f32; 256]> = std::sync::LazyLock::new(|| {
let mut t = [0.0f32; 256];
for (i, v) in t.iter_mut().enumerate() {
let c = i as f32 / 255.0;
*v = if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
};
}
t
});
const LIN2SRGB_N: usize = 4096;
static LINEAR_TO_SRGB: std::sync::LazyLock<[u8; LIN2SRGB_N]> = std::sync::LazyLock::new(|| {
let mut t = [0u8; LIN2SRGB_N];
for (i, v) in t.iter_mut().enumerate() {
let l = i as f32 / (LIN2SRGB_N as f32 - 1.0);
let c = if l <= 0.003_130_8 {
l * 12.92
} else {
1.055 * l.powf(1.0 / 2.4) - 0.055
};
*v = (c * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
}
t
});
#[inline]
fn linear_to_srgb_u8(l: f32) -> u8 {
let idx = (l.clamp(0.0, 1.0) * (LIN2SRGB_N as f32 - 1.0) + 0.5) as usize;
LINEAR_TO_SRGB[idx.min(LIN2SRGB_N - 1)]
}
#[inline]
pub(crate) fn blend_pixel(dst: &mut [u8], off: usize, src: Rgba, a: u8) {
if a == 0 {
return;
}
if a == 255 {
dst[off] = src.r;
dst[off + 1] = src.g;
dst[off + 2] = src.b;
dst[off + 3] = 255;
return;
}
let lut = &*SRGB_TO_LINEAR;
let sa = a as f32 / 255.0;
let da = dst[off + 3] as f32 / 255.0;
let inv = 1.0 - sa;
let out_a = sa + da * inv;
if out_a <= 0.0 {
dst[off] = 0;
dst[off + 1] = 0;
dst[off + 2] = 0;
dst[off + 3] = 0;
return;
}
let src_ch = [src.r, src.g, src.b];
for c in 0..3 {
let s_lin_pm = lut[src_ch[c] as usize] * sa;
let d_lin_pm = if da > 0.0 {
let straight = (dst[off + c] as f32 / da).min(255.0);
lut[straight.round() as usize] * da
} else {
0.0
};
let out_lin_pm = s_lin_pm + d_lin_pm * inv;
let out_srgb_straight = linear_to_srgb_u8(out_lin_pm / out_a) as f32;
dst[off + c] = (out_srgb_straight * out_a).round().clamp(0.0, 255.0) as u8;
}
dst[off + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
}
#[inline]
fn round_rect_sdf(px: f32, py: f32, x: f32, y: f32, w: f32, h: f32, r: f32) -> f32 {
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let qx = (px - cx).abs() - (w / 2.0 - r);
let qy = (py - cy).abs() - (h / 2.0 - r);
let ox = qx.max(0.0);
let oy = qy.max(0.0);
let outside = (ox * ox + oy * oy).sqrt();
let inside = qx.max(qy).min(0.0);
outside + inside - r
}
pub(crate) fn fill_round_rect(
fb: &mut Framebuffer,
x: f32,
y: f32,
w: f32,
h: f32,
r: f32,
color: Rgba,
) {
if w <= 0.0 || h <= 0.0 || color.a == 0 {
return;
}
let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
let (fw, fh) = (fb.width as i32, fb.height as i32);
let x0 = ((x.floor() as i32) - 1).clamp(0, fw);
let y0 = ((y.floor() as i32) - 1).clamp(0, fh);
let x1 = (((x + w).ceil() as i32) + 1).clamp(0, fw);
let y1 = (((y + h).ceil() as i32) + 1).clamp(0, fh);
let ca = color.a as f32;
let pixels = fb.pixels_mut();
for py in y0..y1 {
for px in x0..x1 {
let d = round_rect_sdf(px as f32 + 0.5, py as f32 + 0.5, x, y, w, h, r);
let cov = (0.5 - d).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
let a = (ca * cov).round() as u8;
if a == 0 {
continue;
}
blend_pixel(pixels, ((py * fw + px) * 4) as usize, color, a);
}
}
}
pub(crate) fn fill_rect(fb: &mut Framebuffer, x: f32, y: f32, w: f32, h: f32, color: Rgba) {
if color.a == 0 {
return;
}
let (fw, fh) = (fb.width as i32, fb.height as i32);
let x0 = (x.round() as i32).clamp(0, fw);
let y0 = (y.round() as i32).clamp(0, fh);
let x1 = ((x + w).round() as i32).clamp(0, fw);
let y1 = ((y + h).round() as i32).clamp(0, fh);
let pixels = fb.pixels_mut();
for py in y0..y1 {
for px in x0..x1 {
blend_pixel(pixels, ((py * fw + px) * 4) as usize, color, color.a);
}
}
}
const MAX_ICON_DIM: u32 = 4096;
const MAX_ICON_PIXELS: u64 = 2048 * 2048;
pub fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
let mut decoder = png::Decoder::new(bytes);
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().ok()?;
let hdr = reader.info();
let (hw, hh) = (hdr.width, hdr.height);
if hw == 0 || hh == 0 || hw > MAX_ICON_DIM || hh > MAX_ICON_DIM {
return None;
}
if (hw as u64) * (hh as u64) > MAX_ICON_PIXELS {
return None;
}
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader.next_frame(&mut buf).ok()?;
let (w, h) = (info.width, info.height);
let count = (w as usize).checked_mul(h as usize)?;
let mut out = vec![0u8; count * 4];
match info.color_type {
png::ColorType::Rgba => {
out.copy_from_slice(&buf[..count * 4]);
}
png::ColorType::Rgb => {
for (dst, src) in out.chunks_exact_mut(4).zip(buf.chunks_exact(3)) {
dst[0] = src[0];
dst[1] = src[1];
dst[2] = src[2];
dst[3] = 255;
}
}
png::ColorType::GrayscaleAlpha => {
for (dst, src) in out.chunks_exact_mut(4).zip(buf.chunks_exact(2)) {
dst[0] = src[0];
dst[1] = src[0];
dst[2] = src[0];
dst[3] = src[1];
}
}
png::ColorType::Grayscale => {
for (dst, &g) in out.chunks_exact_mut(4).zip(buf.iter()) {
dst[0] = g;
dst[1] = g;
dst[2] = g;
dst[3] = 255;
}
}
png::ColorType::Indexed => return None,
}
Some((out, w, h))
}
#[inline]
pub(crate) fn scaled(rect: LogicalRect, scale: f32) -> (f32, f32, f32, f32) {
(
rect.origin.x * scale,
rect.origin.y * scale,
rect.size.width * scale,
rect.size.height * scale,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_is_gamma_correct_not_naive_srgb() {
let mut dst = [255u8, 255, 255, 255];
blend_pixel(&mut dst, 0, Rgba::new(0, 0, 0, 255), 127);
assert!(
(185..=191).contains(&dst[0]),
"expected gamma-correct ~188, got {} (128 would be the old naive bug)",
dst[0]
);
assert_eq!(dst[0], dst[1]);
assert_eq!(dst[1], dst[2]);
assert_eq!(dst[3], 255, "opaque over opaque stays opaque");
}
#[test]
fn blend_preserves_opaque_endpoints() {
let mut dst = [10u8, 20, 30, 255];
blend_pixel(&mut dst, 0, Rgba::new(200, 100, 50, 255), 255);
assert_eq!(&dst[..3], &[200, 100, 50]);
let mut untouched = [10u8, 20, 30, 255];
blend_pixel(&mut untouched, 0, Rgba::new(0, 0, 0, 255), 0);
assert_eq!(untouched, [10, 20, 30, 255]);
}
fn png_crc32(bytes: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in bytes {
crc ^= b as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
fn png_header_with_dims(w: u32, h: u32) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']);
let mut chunk = Vec::new();
chunk.extend_from_slice(b"IHDR");
chunk.extend_from_slice(&w.to_be_bytes());
chunk.extend_from_slice(&h.to_be_bytes());
chunk.push(8); chunk.push(6); chunk.push(0); chunk.push(0); chunk.push(0); out.extend_from_slice(&13u32.to_be_bytes()); out.extend_from_slice(&chunk);
out.extend_from_slice(&png_crc32(&chunk).to_be_bytes());
out
}
#[test]
fn decode_png_rejects_oversize_dimensions_before_allocating() {
let bytes = png_header_with_dims(60_000, 60_000);
assert!(decode_png(&bytes).is_none());
}
#[test]
fn decode_png_rejects_a_square_image_over_the_pixel_cap_but_under_the_dim_cap() {
let bytes = png_header_with_dims(3000, 3000);
const { assert!((3000u64 * 3000) <= MAX_ICON_DIM as u64 * MAX_ICON_DIM as u64) };
const { assert!((3000u64 * 3000) > MAX_ICON_PIXELS) };
assert!(decode_png(&bytes).is_none());
}
#[test]
fn decode_png_rejects_malformed_bytes() {
assert!(decode_png(&[]).is_none());
assert!(decode_png(b"not a png at all").is_none());
assert!(decode_png(&[0x89, b'P', b'N', b'G']).is_none()); }
#[test]
fn decode_png_roundtrips_a_small_real_image() {
let mut fb = Framebuffer::new(2, 2);
fb.fill(Rgba::opaque(255, 0, 0));
let bytes = fb.encode_png();
let (rgba, w, h) = decode_png(&bytes).expect("a real, small PNG decodes");
assert_eq!((w, h), (2, 2));
assert_eq!(rgba.len(), 2 * 2 * 4);
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
}
#[test]
fn encode_rgba_png_roundtrips_through_decode() {
let rgba = vec![
255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 9, 8, 7, 0, ];
let png = encode_rgba_png(&rgba, 2, 2).expect("valid RGBA encodes");
let (decoded, w, h) = decode_png(&png).expect("the encoded PNG decodes");
assert_eq!((w, h), (2, 2));
assert_eq!(&decoded[0..8], &rgba[0..8]);
assert_eq!(&decoded[8..12], &rgba[8..12]);
assert_eq!(decoded[15], 0, "the transparent pixel stays transparent");
}
#[test]
fn encode_rgba_png_rejects_bad_dimensions_and_lengths() {
assert!(encode_rgba_png(&[0, 0, 0, 0], 0, 1).is_none());
assert!(encode_rgba_png(&[0, 0, 0, 0], 1, 0).is_none());
assert!(encode_rgba_png(&[0, 0, 0], 1, 1).is_none());
assert!(encode_rgba_png(&[0, 0, 0, 0], 2, 2).is_none());
}
#[test]
fn fill_round_rect_corners_are_more_transparent_than_center() {
let mut fb = Framebuffer::new(20, 20);
fill_round_rect(&mut fb, 2.0, 2.0, 16.0, 16.0, 6.0, Rgba::opaque(10, 20, 30));
let alpha_at = |fb: &Framebuffer, x: u32, y: u32| -> u8 {
fb.pixels()[((y * fb.width() + x) * 4 + 3) as usize]
};
let center = alpha_at(&fb, 10, 10);
let corner = alpha_at(&fb, 2, 2); assert_eq!(center, 255, "well inside the rect should be fully opaque");
assert!(
corner < center,
"a rounded corner should be more transparent than the center (corner={corner}, center={center})"
);
}
#[test]
fn fill_round_rect_clamps_radius_to_half_the_smaller_dimension() {
let mut fb = Framebuffer::new(10, 10);
fill_round_rect(&mut fb, 0.0, 0.0, 8.0, 4.0, 1000.0, Rgba::opaque(1, 2, 3));
let idx = ((2 * fb.width() + 4) * 4 + 3) as usize;
assert_eq!(
fb.pixels()[idx],
255,
"center of the clamped fill is opaque"
);
}
}