use std::sync::{Arc, OnceLock};
use image::DynamicImage;
use usvg::fontdb;
static SHARED_FONTDB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
fn shared_fontdb() -> Arc<fontdb::Database> {
SHARED_FONTDB
.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
Arc::new(db)
})
.clone()
}
pub fn warm_fontdb() {
let _ = shared_fontdb();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SvgScaleMode {
Natural,
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SvgSizing {
pub envelope: Option<(u16, u16)>,
pub font_size: Option<(u16, u16)>,
pub mode: SvgScaleMode,
}
impl SvgSizing {
fn envelope_px(self) -> Option<(u32, u32)> {
let (envelope, font_size) = (self.envelope?, self.font_size?);
let max_w = u32::from(envelope.0).saturating_mul(u32::from(font_size.0));
let max_h = u32::from(envelope.1).saturating_mul(u32::from(font_size.1));
(max_w != 0 && max_h != 0).then_some((max_w, max_h))
}
fn scale_for(self, natural_w: u32, natural_h: u32) -> f32 {
let Some((max_w_px, max_h_px)) = self.envelope_px() else {
return 1.0;
};
let fit = (max_w_px as f32 / natural_w as f32).min(max_h_px as f32 / natural_h as f32);
let fit = if fit.is_finite() && fit > 0.0 {
fit
} else {
1.0
};
match self.mode {
SvgScaleMode::Natural => fit.min(1.0),
SvgScaleMode::Fill => fit,
}
}
}
const MAX_RASTER_DIM: u32 = 8_192;
const MAX_RASTER_PIXELS: u64 = 4_000_000;
fn budget_shrink(px_w: u32, px_h: u32) -> f64 {
let (w, h) = (f64::from(px_w), f64::from(px_h));
let mut shrink = 1.0f64;
let longest = w.max(h);
if longest > f64::from(MAX_RASTER_DIM) {
shrink = f64::from(MAX_RASTER_DIM) / longest;
}
let area = (w * shrink) * (h * shrink);
if area > MAX_RASTER_PIXELS as f64 {
shrink *= (MAX_RASTER_PIXELS as f64 / area).sqrt();
}
shrink
}
#[derive(Debug, thiserror::Error)]
pub enum SvgError {
#[error("svg parse failed: {0}")]
Parse(String),
#[error("svg raster failed: {0}")]
Raster(String),
#[error("svg png decode failed: {0}")]
Decode(String),
}
pub fn rasterize_svg(
svg: &str,
sizing: SvgSizing,
background: Option<[u8; 4]>,
) -> Result<DynamicImage, SvgError> {
let mut opt = usvg::Options {
fontdb: shared_fontdb(),
..Default::default()
};
opt.image_href_resolver.resolve_string = Box::new(|_href, _opts| None);
let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(format!("{e}")))?;
let size = tree.size();
let natural_w = (size.width().ceil() as u32).max(1);
let natural_h = (size.height().ceil() as u32).max(1);
let mut scale = sizing.scale_for(natural_w, natural_h);
let mut px_w = ((natural_w as f32 * scale).ceil() as u32).max(1);
let mut px_h = ((natural_h as f32 * scale).ceil() as u32).max(1);
if let Some((max_w_px, max_h_px)) = sizing.envelope_px() {
px_w = px_w.min(max_w_px).max(1);
px_h = px_h.min(max_h_px).max(1);
}
let shrink = budget_shrink(px_w, px_h);
if shrink < 1.0 {
scale = (f64::from(scale) * shrink) as f32;
px_w = ((f64::from(px_w) * shrink).floor() as u32).max(1);
px_h = ((f64::from(px_h) * shrink).floor() as u32).max(1);
}
let mut pixmap = resvg::tiny_skia::Pixmap::new(px_w, px_h)
.ok_or_else(|| SvgError::Raster(format!("pixmap alloc failed: {px_w}x{px_h}")))?;
if let Some([r, g, b, a]) = background {
pixmap.fill(resvg::tiny_skia::Color::from_rgba8(r, g, b, a));
}
resvg::render(
&tree,
resvg::tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let png_bytes = pixmap
.encode_png()
.map_err(|e| SvgError::Raster(format!("png encode: {e}")))?;
image::load_from_memory(&png_bytes).map_err(|e| SvgError::Decode(format!("{e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn natural(envelope: Option<(u16, u16)>) -> SvgSizing {
SvgSizing {
envelope,
font_size: Some((8, 16)),
mode: SvgScaleMode::Natural,
}
}
fn fill(envelope: Option<(u16, u16)>) -> SvgSizing {
SvgSizing {
envelope,
font_size: Some((8, 16)),
mode: SvgScaleMode::Fill,
}
}
#[test]
fn natural_small_svg_is_not_upscaled() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<rect width="200" height="150" fill="#eef"/>
</svg>"##;
let image = rasterize_svg(svg, natural(Some((80, 24))), None).expect("rasterize");
assert_eq!(image.width(), 200);
assert_eq!(image.height(), 150);
}
#[test]
fn natural_large_svg_downscales_to_fit_envelope() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200">
<rect width="1600" height="1200" fill="#eef"/>
</svg>"##;
let image = rasterize_svg(svg, natural(Some((80, 24))), None).expect("rasterize");
assert!(image.width() <= 640);
assert!(image.height() <= 384);
assert_eq!(image.height(), 384);
}
#[test]
fn natural_preserves_transparency_when_no_background() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"></svg>"##;
let image = rasterize_svg(svg, natural(None), None).expect("rasterize");
let rgba = image.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0)[3], 0, "corner must stay transparent");
}
#[test]
fn fill_small_svg_upscales_to_envelope() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<rect width="200" height="150" fill="#eef"/>
</svg>"##;
let image = rasterize_svg(svg, fill(Some((80, 24))), None).expect("rasterize");
assert_eq!(image.height(), 384, "should fill the envelope height");
assert_eq!(image.width(), 512);
}
#[test]
fn fill_with_white_background_is_opaque() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10"></svg>"##;
let image = rasterize_svg(svg, fill(None), Some([255, 255, 255, 255])).expect("rasterize");
let rgba = image.to_rgba8();
let px = rgba.get_pixel(0, 0);
assert_eq!(px[3], 255, "white fill must be opaque");
assert_eq!([px[0], px[1], px[2]], [255, 255, 255]);
}
#[test]
fn no_envelope_keeps_natural_size() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="37" height="23" viewBox="0 0 37 23">
<rect width="37" height="23" fill="#fff"/>
</svg>"##;
let image = rasterize_svg(svg, natural(None), None).expect("rasterize");
assert_eq!(image.width(), 37);
assert_eq!(image.height(), 23);
}
fn shrunk(px_w: u32, px_h: u32) -> (u64, u64) {
let shrink = budget_shrink(px_w, px_h);
if shrink >= 1.0 {
return (u64::from(px_w), u64::from(px_h));
}
(
((f64::from(px_w) * shrink).floor() as u64).max(1),
((f64::from(px_h) * shrink).floor() as u64).max(1),
)
}
#[test]
fn budget_shrink_leaves_ordinary_sizes_alone() {
assert_eq!(budget_shrink(800, 600), 1.0);
assert_eq!(budget_shrink(512, 384), 1.0);
}
#[test]
fn budget_shrink_caps_area_with_the_aspect_ratio_intact() {
let (w, h) = shrunk(4000, 3000);
assert!(w * h <= MAX_RASTER_PIXELS, "area {} over budget", w * h);
let aspect = w as f64 / h as f64;
assert!((aspect - 4.0 / 3.0).abs() < 1e-2, "aspect {aspect} drifted");
}
#[test]
fn budget_shrink_caps_the_longest_side_of_a_sliver() {
let (w, h) = shrunk(100_000, 4);
assert!(w <= u64::from(MAX_RASTER_DIM), "width {w} over the dim cap");
assert!(h >= 1, "the short axis must not floor away to zero");
assert!(w * h <= MAX_RASTER_PIXELS);
}
#[test]
fn oversized_svg_without_an_envelope_is_scaled_into_the_budget() {
let svg = r##"<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="3000" viewBox="0 0 4000 3000">
<rect width="4000" height="3000" fill="#eef"/>
</svg>"##;
let image = rasterize_svg(svg, natural(None), None).expect("rasterize");
let (w, h) = (u64::from(image.width()), u64::from(image.height()));
assert!(
w * h <= MAX_RASTER_PIXELS,
"{w}x{h} = {} px exceeds the {MAX_RASTER_PIXELS} px budget",
w * h
);
assert!(w <= u64::from(MAX_RASTER_DIM) && h <= u64::from(MAX_RASTER_DIM));
assert!(w > h, "the 4:3 aspect ratio must survive the clamp");
}
#[test]
fn image_href_to_local_file_is_not_loaded() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("secret.png");
let buf = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
image::DynamicImage::ImageRgba8(buf).save(&png).unwrap();
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10" height="10" viewBox="0 0 10 10"><image x="0" y="0" width="10" height="10" xlink:href="{}"/></svg>"##,
png.display()
);
let image = rasterize_svg(&svg, natural(None), None).expect("rasterize");
let rgba = image.to_rgba8();
assert_eq!(
rgba.get_pixel(5, 5)[3],
0,
"a local-file <image href> must not be loaded into the render"
);
}
#[test]
fn malformed_svg_returns_parse_error() {
let err = rasterize_svg("not an svg at all", natural(None), None).unwrap_err();
assert!(matches!(err, SvgError::Parse(_)));
}
}