use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use resvg::tiny_skia;
use resvg::usvg::{self, fontdb};
use vello::peniko::{Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
const SVG_SUPERSAMPLE: f32 = 2.0;
const SVG_MAX_RASTER: f32 = 4096.0;
static SVG_FONTS: LazyLock<Arc<fontdb::Database>> = LazyLock::new(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
Arc::new(db)
});
pub struct LoadedImage {
pub brush: ImageBrush,
pub width: u32,
pub height: u32,
pub display_w: f32,
pub display_h: f32,
}
#[derive(Clone)]
pub enum ImageState {
Loading,
Loaded(Arc<LoadedImage>),
Failed,
}
#[derive(Clone, Default)]
pub struct ImageCache {
inner: Arc<Mutex<HashMap<String, ImageState>>>,
}
impl ImageCache {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, url: &str) -> Option<ImageState> {
self.inner.lock().unwrap().get(url).cloned()
}
pub fn contains(&self, url: &str) -> bool {
self.inner.lock().unwrap().contains_key(url)
}
pub fn mark_loading(&self, url: &str) {
self.inner
.lock()
.unwrap()
.insert(url.to_string(), ImageState::Loading);
}
pub fn set_loaded(&self, url: &str, image: LoadedImage) {
self.inner
.lock()
.unwrap()
.insert(url.to_string(), ImageState::Loaded(Arc::new(image)));
}
pub fn set_failed(&self, url: &str) {
self.inner
.lock()
.unwrap()
.insert(url.to_string(), ImageState::Failed);
}
}
pub fn decode(bytes: &[u8]) -> Option<LoadedImage> {
if let Ok(img) = image::load_from_memory(bytes) {
let rgba = img.to_rgba8();
let width = rgba.width();
let height = rgba.height();
let data = ImageData {
data: Blob::new(Arc::new(rgba.into_raw())),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width,
height,
};
return Some(LoadedImage {
brush: ImageBrush::new(data),
width,
height,
display_w: width as f32,
display_h: height as f32,
});
}
looks_like_svg(bytes).then(|| decode_svg(bytes)).flatten()
}
fn looks_like_svg(bytes: &[u8]) -> bool {
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
let trimmed = bytes
.iter()
.position(|b| !b.is_ascii_whitespace())
.map_or(&[][..], |i| &bytes[i..]);
if trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<svg") {
return true;
}
let head = &trimmed[..trimmed.len().min(1024)];
head.windows(4).any(|w| w == b"<svg")
}
fn decode_svg(bytes: &[u8]) -> Option<LoadedImage> {
let opt = usvg::Options {
fontdb: SVG_FONTS.clone(),
..Default::default()
};
let tree = usvg::Tree::from_data(bytes, &opt).ok()?;
let size = tree.size();
let (dw, dh) = (size.width(), size.height());
if dw <= 0.0 || dh <= 0.0 {
return None;
}
let k = SVG_SUPERSAMPLE.min(SVG_MAX_RASTER / dw.max(dh));
let rw = ((dw * k).round() as u32).max(1);
let rh = ((dh * k).round() as u32).max(1);
let mut pixmap = tiny_skia::Pixmap::new(rw, rh)?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(k, k),
&mut pixmap.as_mut(),
);
let data = ImageData {
data: Blob::new(Arc::new(pixmap.data().to_vec())),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::AlphaPremultiplied,
width: rw,
height: rh,
};
Some(LoadedImage {
brush: ImageBrush::new(data),
width: rw,
height: rh,
display_w: dw,
display_h: dh,
})
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageFormat as CrateImageFormat, RgbaImage};
use std::io::Cursor;
fn tiny_png(w: u32, h: u32) -> Vec<u8> {
let img = RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 255]));
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), CrateImageFormat::Png)
.unwrap();
buf
}
#[test]
fn decode_reports_dimensions() {
let png = tiny_png(7, 3);
let loaded = decode(&png).expect("tiny PNG should decode");
assert_eq!((loaded.width, loaded.height), (7, 3));
}
#[test]
fn decode_rejects_garbage() {
assert!(decode(b"not an image").is_none());
}
#[test]
fn decode_rasterizes_svg() {
let svg = r#"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='20'><rect width='40' height='20' fill='#4c1'/></svg>"#;
let loaded = decode(svg.as_bytes()).expect("inline SVG should decode");
assert!((loaded.display_w - 40.0).abs() < 0.5);
assert!((loaded.display_h - 20.0).abs() < 0.5);
assert_eq!((loaded.width, loaded.height), (80, 40));
}
#[test]
fn cache_shares_state_across_clones() {
let cache = ImageCache::new();
let other = cache.clone();
assert!(cache.get("x.png").is_none());
cache.mark_loading("x.png");
assert!(matches!(other.get("x.png"), Some(ImageState::Loading)));
let loaded = decode(&tiny_png(2, 2)).unwrap();
cache.set_loaded("x.png", loaded);
assert!(matches!(other.get("x.png"), Some(ImageState::Loaded(_))));
}
}