Files
endorphin-tessera-gui/src/render/image.rs
T

194 lines
7.0 KiB
Rust

//! Immutable, bounded RGBA images shared by display lists. Decoding and text
//! preparation happen outside the frame loop; the CPU backend samples them.
use crate::{
display_list::Color,
geometry::Rect,
text::{self, TextStyle},
};
use std::{io::Cursor, sync::Arc};
#[derive(Debug, serde::Serialize)]
pub struct Image {
pub width: u32,
pub height: u32,
pub id: u64,
#[serde(skip)]
pixels: Vec<u8>,
#[serde(skip)]
pub outline: Vec<crate::geometry::Point>,
}
impl Image {
pub fn png(bytes: &[u8]) -> anyhow::Result<Arc<Self>> {
let mut decoder = png::Decoder::new(Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let info = reader.info();
anyhow::ensure!(
info.width > 0
&& info.height > 0
&& u64::from(info.width) * u64::from(info.height) <= 4_194_304,
"image exceeds pixel budget"
);
let mut buffer = vec![
0;
reader
.output_buffer_size()
.ok_or_else(|| anyhow::anyhow!("image size overflow"))?
];
let info = reader.next_frame(&mut buffer)?;
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for chunk in buffer[..info.buffer_size()].chunks_exact(info.color_type.samples()) {
let rgba = match info.color_type {
png::ColorType::Rgba => [chunk[0], chunk[1], chunk[2], chunk[3]],
png::ColorType::Rgb => [chunk[0], chunk[1], chunk[2], 255],
png::ColorType::Grayscale => [chunk[0], chunk[0], chunk[0], 255],
png::ColorType::GrayscaleAlpha => [chunk[0], chunk[0], chunk[0], chunk[1]],
png::ColorType::Indexed => anyhow::bail!("unexpanded indexed PNG"),
};
pixels.extend_from_slice(&rgba);
}
let outline = alpha_hull(&pixels, info.width, info.height);
Ok(Arc::new(Self {
width: info.width,
height: info.height,
pixels,
id: next_id(),
outline,
}))
}
/// Cache a shaped word at the actual device resolution. Padding preserves
/// italic/serif overhangs. Returns its logical image rectangle and advance.
pub fn text(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<(Arc<Self>, Rect, f32), &'static str> {
let size = text::measure(value, style);
let pad = (style.font_size * 0.3).ceil();
let width = ((size.width + pad * 2.) * scale).ceil().max(1.) as u32;
let height = ((size.height + pad * 2.) * scale).ceil().max(1.) as u32;
if u64::from(width) * u64::from(height) > 1_048_576 {
return Err("prepared text image exceeds pixel budget");
}
let mut pixels = vec![0; width as usize * height as usize * 4];
text::draw_clipped(
value,
style.scaled(scale),
color,
None,
Rect::new(-pad * scale, -pad * scale, width as f32, height as f32),
|x, y, c| {
let x = x + (pad * scale).round() as i32;
let y = y + (pad * scale).round() as i32;
if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 {
return;
}
let i = (y as usize * width as usize + x as usize) * 4;
// All glyphs have the same ink color; alpha-over preserves overlaps.
pixels[i..i + 3].copy_from_slice(&[c.red, c.green, c.blue]);
pixels[i + 3] = (u16::from(c.alpha)
+ u16::from(pixels[i + 3]) * (255 - u16::from(c.alpha)) / 255)
as u8;
},
);
Ok((
Arc::new(Self {
width,
height,
pixels,
id: next_id(),
outline: vec![],
}),
Rect::new(-pad, -pad, width as f32 / scale, height as f32 / scale),
size.width,
))
}
pub fn sample(&self, u: f32, v: f32, opacity: f32) -> Color {
let x = u * self.width as f32 - 0.5;
let y = v * self.height as f32 - 0.5;
let (ix, iy) = (x.floor() as i32, y.floor() as i32);
let (fx, fy) = (x - x.floor(), y - y.floor());
let mut sum = [0.; 4];
for (dx, dy, w) in [
(0, 0, (1. - fx) * (1. - fy)),
(1, 0, fx * (1. - fy)),
(0, 1, (1. - fx) * fy),
(1, 1, fx * fy),
] {
let px = (ix + dx).clamp(0, self.width as i32 - 1) as usize;
let py = (iy + dy).clamp(0, self.height as i32 - 1) as usize;
let i = (py * self.width as usize + px) * 4;
let a = f32::from(self.pixels[i + 3]) * w;
for (j, s) in sum.iter_mut().enumerate().take(3) {
*s += f32::from(self.pixels[i + j]) * a;
}
sum[3] += a;
}
if sum[3] <= 0. {
return Color::rgba(0, 0, 0, 0);
}
Color::rgba(
(sum[0] / sum[3]).round() as u8,
(sum[1] / sum[3]).round() as u8,
(sum[2] / sum[3]).round() as u8,
(sum[3] * opacity).round() as u8,
)
}
}
/// Conservative convex silhouette from opaque row endpoints. Unlike an image's
/// rectangle, this excludes empty transparent margins. Generated once per PNG.
fn alpha_hull(pixels: &[u8], width: u32, height: u32) -> Vec<crate::geometry::Point> {
use crate::geometry::Point;
let mut points = Vec::new();
for y in 0..height {
let xs: Vec<_> = (0..width)
.filter(|&x| pixels[((y * width + x) * 4 + 3) as usize] >= 16)
.collect();
if let (Some(&left), Some(&right)) = (xs.first(), xs.last()) {
for x in [left as f32, right as f32 + 1.] {
for yy in [y as f32, y as f32 + 1.] {
points.push(Point::new(x, yy));
}
}
}
}
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
points.dedup();
if points.len() < 3 {
return points;
}
let cross =
|a: Point, b: Point, c: Point| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
let mut hull: Vec<Point> = Vec::new();
for &p in &points {
while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
let lower = hull.len();
for &p in points.iter().rev().skip(1) {
while hull.len() > lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
hull.pop();
hull
}
fn next_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PartialEq for Image {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}