use std::io::Cursor;
use std::sync::{Mutex, OnceLock};
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
use bytes::Bytes;
use image::{DynamicImage, GenericImageView, ImageFormat, Rgba};
use crate::preview_state::PreviewState;
const FONT_BYTES: &[u8] = include_bytes!("assets/DejaVuSans.ttf");
static FONT: OnceLock<FontRef<'static>> = OnceLock::new();
fn font() -> &'static FontRef<'static> {
FONT.get_or_init(|| FontRef::try_from_slice(FONT_BYTES).expect("embedded DejaVuSans TTF valid"))
}
pub fn render(jpeg: &Bytes, state: PreviewState) -> Bytes {
let Some(caption) = state.caption() else {
return jpeg.clone();
};
let img = match image::load_from_memory_with_format(jpeg, ImageFormat::Jpeg) {
Ok(i) => i,
Err(_) => return jpeg.clone(),
};
let overlaid = draw_caption(img, caption);
let mut out = Vec::with_capacity(jpeg.len() + 512);
if overlaid
.write_to(&mut Cursor::new(&mut out), ImageFormat::Jpeg)
.is_err()
{
return jpeg.clone();
}
Bytes::from(out)
}
const TEXT_HEIGHT_FRAC: f32 = 0.10;
const OUTLINE_FRAC: f32 = 0.06;
const BOTTOM_PADDING_FRAC: f32 = 0.02;
const LEFT_PADDING_FRAC: f32 = 0.02;
fn draw_caption(img: DynamicImage, caption: &str) -> DynamicImage {
let (w, h) = img.dimensions();
let mut rgba = img.to_rgba8();
let font = font();
let h_f = h as f32;
let scale = PxScale::from(h_f * TEXT_HEIGHT_FRAC);
let scaled = font.as_scaled(scale);
let descent = scaled.descent(); let outline_r = (scale.y * OUTLINE_FRAC).round().max(2.0) as i32;
let baseline_y = h_f - h_f * BOTTOM_PADDING_FRAC - descent.abs() - outline_r as f32;
let mut cursor_x = h_f * LEFT_PADDING_FRAC + outline_r as f32;
for ch in caption.chars() {
let glyph_id = font.glyph_id(ch);
let h_advance = scaled.h_advance(glyph_id);
let glyph = glyph_id.with_scale_and_position(scale, ab_glyph::point(cursor_x, baseline_y));
if let Some(outlined) = font.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
let bx = bounds.min.x as i32;
let by = bounds.min.y as i32;
let bw = (bounds.max.x - bounds.min.x).ceil() as usize;
let bh = (bounds.max.y - bounds.min.y).ceil() as usize;
if bw == 0 || bh == 0 {
cursor_x += h_advance;
continue;
}
let mut cov = vec![0.0f32; bw * bh];
outlined.draw(|gx, gy, v| {
let gxi = gx as usize;
let gyi = gy as usize;
if gxi < bw && gyi < bh {
cov[gyi * bw + gxi] = v.clamp(0.0, 1.0);
}
});
let r2 = outline_r * outline_r;
for oy in -outline_r..(bh as i32 + outline_r) {
for ox in -outline_r..(bw as i32 + outline_r) {
let px_x = bx + ox;
let px_y = by + oy;
if px_x < 0 || px_y < 0 || (px_x as u32) >= w || (px_y as u32) >= h {
continue;
}
let mut max_cov = 0.0f32;
'disc: for dy in -outline_r..=outline_r {
let sy = oy + dy;
if sy < 0 || sy as usize >= bh {
continue;
}
for dx in -outline_r..=outline_r {
if dx * dx + dy * dy > r2 {
continue;
}
let sx = ox + dx;
if sx < 0 || sx as usize >= bw {
continue;
}
let v = cov[sy as usize * bw + sx as usize];
if v > max_cov {
max_cov = v;
if max_cov >= 1.0 {
break 'disc;
}
}
}
}
if max_cov > 0.05 {
let px_ref = rgba.get_pixel_mut(px_x as u32, px_y as u32);
*px_ref = blend(*px_ref, Rgba([0, 0, 0, (max_cov * 255.0) as u8]));
}
}
}
for gy in 0..bh {
for gx in 0..bw {
let v = cov[gy * bw + gx];
if v <= 0.0 {
continue;
}
let px_x = bx + gx as i32;
let px_y = by + gy as i32;
if px_x < 0 || px_y < 0 || (px_x as u32) >= w || (px_y as u32) >= h {
continue;
}
let px_ref = rgba.get_pixel_mut(px_x as u32, px_y as u32);
*px_ref = blend(*px_ref, Rgba([255, 255, 255, (v * 255.0) as u8]));
}
}
}
cursor_x += h_advance;
}
DynamicImage::ImageRgba8(rgba)
}
fn blend(bg: Rgba<u8>, fg: Rgba<u8>) -> Rgba<u8> {
let a = fg[3] as u16;
let inv = 255 - a;
Rgba([
((bg[0] as u16 * inv + fg[0] as u16 * a) / 255) as u8,
((bg[1] as u16 * inv + fg[1] as u16 * a) / 255) as u8,
((bg[2] as u16 * inv + fg[2] as u16 * a) / 255) as u8,
255,
])
}
pub struct OverlayCache {
inner: Mutex<Option<CacheEntry>>,
}
struct CacheEntry {
state: PreviewState,
input_hash: u64,
rendered: Bytes,
}
impl OverlayCache {
pub fn new() -> Self {
Self {
inner: Mutex::new(None),
}
}
pub fn render(&self, jpeg: &Bytes, state: PreviewState) -> Bytes {
let input_hash = fast_hash(jpeg);
{
let guard = self.inner.lock().unwrap_or_else(|p| p.into_inner());
if let Some(entry) = guard.as_ref() {
if entry.state == state && entry.input_hash == input_hash {
return entry.rendered.clone();
}
}
}
let rendered = render(jpeg, state);
*self.inner.lock().unwrap_or_else(|p| p.into_inner()) = Some(CacheEntry {
state,
input_hash,
rendered: rendered.clone(),
});
rendered
}
}
impl Default for OverlayCache {
fn default() -> Self {
Self::new()
}
}
pub fn rendered_preview(
jpeg: Bytes,
overlay_enabled: bool,
state: PreviewState,
cache: Option<&OverlayCache>,
) -> Bytes {
if !overlay_enabled {
return jpeg;
}
match cache {
Some(c) => c.render(&jpeg, state),
None => render(&jpeg, state),
}
}
fn fast_hash(b: &Bytes) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
b.as_ref().hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_jpeg() -> Bytes {
let img = image::RgbImage::from_pixel(64, 64, image::Rgb([127, 127, 127]));
let mut out = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.expect("encode sample");
Bytes::from(out)
}
#[test]
fn render_live_is_passthrough() {
let src = sample_jpeg();
let out = render(&src, PreviewState::Live);
assert_eq!(out, src);
}
#[test]
fn render_nonlive_changes_bytes() {
let src = sample_jpeg();
let out = render(&src, PreviewState::Connecting);
assert_ne!(out, src);
}
#[test]
fn render_deterministic_for_same_inputs() {
let src = sample_jpeg();
let a = render(&src, PreviewState::Sleeping);
let b = render(&src, PreviewState::Sleeping);
assert_eq!(a, b);
}
#[test]
fn render_fallback_on_invalid_jpeg_returns_input() {
let bogus = Bytes::from_static(b"not a jpeg");
let out = render(&bogus, PreviewState::Sleeping);
assert_eq!(out, bogus);
}
#[test]
fn cache_serves_repeat_request_from_storage() {
let cache = OverlayCache::new();
let src = sample_jpeg();
let a = cache.render(&src, PreviewState::Sleeping);
let b = cache.render(&src, PreviewState::Sleeping);
assert_eq!(a, b);
}
#[test]
fn cache_invalidates_on_state_change() {
let cache = OverlayCache::new();
let src = sample_jpeg();
let a = cache.render(&src, PreviewState::Sleeping);
let b = cache.render(&src, PreviewState::Connecting);
assert_ne!(a, b);
}
#[test]
fn overlay_cache_default_equals_new() {
let a = OverlayCache::default();
let b = OverlayCache::new();
let src = sample_jpeg();
assert_eq!(
a.render(&src, PreviewState::Sleeping),
b.render(&src, PreviewState::Sleeping)
);
}
#[test]
fn overlay_renders_on_tiny_image_without_panic() {
let img = image::RgbImage::from_pixel(16, 16, image::Rgb([127, 127, 127]));
let mut out = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.unwrap();
let src = Bytes::from(out);
let rendered = render(&src, PreviewState::Connecting);
assert!(rendered.starts_with(&[0xFF, 0xD8, 0xFF]));
}
#[test]
fn overlay_caption_with_space_character_handles_empty_glyph_bbox() {
let cache = OverlayCache::new();
let src = sample_jpeg();
let a = cache.render(&src, PreviewState::Connecting);
let b = cache.render(&src, PreviewState::Connecting);
assert_eq!(a, b);
}
}