use std::path::Path;
use std::sync::{Arc, OnceLock};
use image::DynamicImage;
use resvg::tiny_skia;
use resvg::usvg;
const HARD_MAX_PX: u32 = 4096;
fn shared_fontdb() -> Arc<usvg::fontdb::Database> {
static DB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = usvg::fontdb::Database::new();
db.load_system_fonts();
Arc::new(db)
})
.clone()
}
pub fn warm_fontdb() {
let _ = shared_fontdb();
}
pub fn rasterize(path: &Path, max_px: u32) -> Option<DynamicImage> {
let data = std::fs::read(path).ok()?;
rasterize_bytes(&data, path, max_px)
}
pub fn intrinsic_size(path: &Path) -> Option<(u32, u32)> {
let data = std::fs::read(path).ok()?;
let opt = usvg::Options {
resources_dir: path.parent().map(Path::to_path_buf),
fontdb: shared_fontdb(),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(&data, &opt).ok()?;
let size = tree.size();
let (w, h) = (size.width(), size.height());
if !(w > 0.0 && h > 0.0) {
return None;
}
Some((w.ceil() as u32, h.ceil() as u32))
}
pub fn rasterize_bytes(data: &[u8], path: &Path, max_px: u32) -> Option<DynamicImage> {
let opt = usvg::Options {
resources_dir: path.parent().map(Path::to_path_buf),
fontdb: shared_fontdb(),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(data, &opt).ok()?;
let size = tree.size();
let (w0, h0) = (size.width(), size.height());
if !(w0 > 0.0 && h0 > 0.0) {
return None;
}
let target = (max_px.max(1) as f32).min(HARD_MAX_PX as f32);
let scale = (target / w0.max(h0)).max(1.0);
let pw = ((w0 * scale).ceil() as u32).clamp(1, HARD_MAX_PX);
let ph = ((h0 * scale).ceil() as u32).clamp(1, HARD_MAX_PX);
let mut pixmap = tiny_skia::Pixmap::new(pw, ph)?;
let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut rgba = Vec::with_capacity((pw * ph * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.push(c.red());
rgba.push(c.green());
rgba.push(c.blue());
rgba.push(c.alpha());
}
let buf = image::RgbaImage::from_raw(pw, ph, rgba)?;
Some(DynamicImage::ImageRgba8(buf))
}
#[cfg(test)]
mod tests {
use super::*;
const TINY_SVG: &[u8] =
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##;
#[test]
fn rasterizes_small_svg_upscaled_and_opaque() {
let img = rasterize_bytes(TINY_SVG, Path::new("t.svg"), 800).expect("should rasterize");
assert_eq!(img.width(), 800);
assert_eq!(img.height(), 400);
let p = img.to_rgba8();
let px = p.get_pixel(400, 200);
assert_eq!(px[3], 255, "fill should be opaque");
assert!(
px[0] > 200 && px[1] < 60 && px[2] < 60,
"fill should be red"
);
}
#[test]
fn svg_max_px_controls_raster_size() {
let small = rasterize_bytes(TINY_SVG, Path::new("t.svg"), 400).unwrap();
assert_eq!(small.width(), 400, "max_px=400 → 最大辺 400");
let big = rasterize_bytes(TINY_SVG, Path::new("t.svg"), 1200).unwrap();
assert_eq!(big.width(), 1200, "max_px=1200 → 最大辺 1200");
}
#[test]
fn invalid_svg_returns_none() {
assert!(rasterize_bytes(b"not an svg at all", Path::new("x.svg"), 800).is_none());
}
#[test]
fn intrinsic_size_reads_declared_size_without_rasterizing() {
let dir = std::env::temp_dir().join("konoma_svg_intrinsic_test");
let _ = std::fs::create_dir_all(&dir);
let svg = dir.join("badge.svg");
std::fs::write(&svg, TINY_SVG).unwrap();
assert_eq!(intrinsic_size(&svg), Some((20, 10)), "declared 20x10");
let bad = dir.join("not.svg");
std::fs::write(&bad, b"not an svg at all").unwrap();
assert!(intrinsic_size(&bad).is_none(), "非 SVG は None");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn warm_fontdb_primes_shared_singleton() {
warm_fontdb();
let a = shared_fontdb();
let b = shared_fontdb();
assert!(
std::sync::Arc::ptr_eq(&a, &b),
"shared_fontdb はキャッシュした同一インスタンスを返す"
);
let with_text = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><text x="2" y="14">hi</text></svg>"##;
assert!(
rasterize_bytes(with_text, Path::new("t.svg"), 200).is_some(),
"フォント DB 準備後はテキスト SVG も描ける"
);
}
}