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;
pub(crate) 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();
install_fallback_sans_serif(&mut db);
Arc::new(db)
})
.clone()
}
const FALLBACK_SANS_SERIF_FACE: &str = "KaTeX_SansSerif-Regular.ttf";
fn resolves_sans_serif(db: &usvg::fontdb::Database) -> bool {
db.query(&usvg::fontdb::Query {
families: &[usvg::fontdb::Family::SansSerif],
weight: usvg::fontdb::Weight::NORMAL,
stretch: usvg::fontdb::Stretch::Normal,
style: usvg::fontdb::Style::Normal,
})
.is_some()
}
fn install_fallback_sans_serif(db: &mut usvg::fontdb::Database) -> bool {
if resolves_sans_serif(db) {
return false;
}
let Some(bytes) = ratex_katex_fonts::ttf_bytes(FALLBACK_SANS_SERIF_FACE) else {
return false;
};
let first_new_face = db.len();
db.load_font_data(bytes.into_owned());
let Some(family) = db
.faces()
.skip(first_new_face)
.find_map(|face| face.families.first().map(|(name, _)| name.clone()))
else {
return false;
};
db.set_sans_serif_family(family);
true
}
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 intrinsic_size_bytes(data: &[u8]) -> Option<(u32, u32)> {
let opt = usvg::Options {
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 m = w0.max(h0);
let scale = (target / m).max(1.0).min(HARD_MAX_PX as f32 / m);
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::*;
use crate::test_support::unique_tmp;
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 oversize_svg_shrinks_to_fit_instead_of_cropping() {
let wide: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8000" height="100"><rect width="8000" height="100" fill="#f00"/><rect x="7900" width="100" height="100" fill="#00f"/></svg>"##;
let img = rasterize_bytes(wide, Path::new("t.svg"), 800).expect("should rasterize");
assert!(img.width() <= HARD_MAX_PX, "最大辺は HARD_MAX 以下");
assert!(img.width() >= 4000, "縮小フィット(切り落としでなく)");
let p = img.to_rgba8();
let px = p.get_pixel(img.width() - 5, img.height() / 2);
assert_eq!(px[3], 255, "右端まで描画されている");
assert!(px[2] > 200 && px[0] < 60, "右端の青矩形が残る: {px:?}");
}
#[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 = unique_tmp("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);
}
fn raster(svg: &str, db: Arc<usvg::fontdb::Database>) -> Vec<u8> {
let opt = usvg::Options {
fontdb: db,
..usvg::Options::default()
};
let tree = usvg::Tree::from_str(svg, &opt).expect("the renderer's own SVG must parse");
let size = tree.size();
let w = (size.width().ceil() as u32).max(1);
let h = (size.height().ceil() as u32).max(1);
let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("pixmap should allocate");
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
pixmap.take()
}
#[test]
fn the_embedded_face_draws_a_diagram_on_a_machine_with_no_fonts() {
use crate::preview::mermaid::render;
use crate::preview::mermaid::text_metrics::{TextMetrics, FONT_SIZE};
let bare = usvg::fontdb::Database::new();
assert_eq!(bare.len(), 0, "the stand-in machine has no fonts at all");
assert!(!resolves_sans_serif(&bare));
assert!(
TextMetrics::resolve(Arc::new(bare)).is_err(),
"with no font the renderer's gate must refuse — this is the regression"
);
let mut fallback = usvg::fontdb::Database::new();
assert!(
install_fallback_sans_serif(&mut fallback),
"the embedded face must register when nothing else answers to sans-serif"
);
assert!(
resolves_sans_serif(&fallback),
"and sans-serif now resolves"
);
let fallback = Arc::new(fallback);
let metrics = TextMetrics::resolve(fallback.clone())
.expect("the gate must pass once the embedded face is registered");
assert!(
!metrics.has_missing_glyph("Start"),
"the embedded face must carry Latin glyphs, not merely answer to the family name"
);
let width = metrics.measure("Start", FONT_SIZE);
assert!(
(20.0..60.0).contains(&width),
"five letters at {FONT_SIZE}px should measure like letters, not like stand-ins: {width}px"
);
let svg = render::render("flowchart LR\n A[Start] --> B[Stop]\n", "dark")
.expect("the diagram must lay out");
let unlabelled = raster(&svg, Arc::new(usvg::fontdb::Database::new()));
let labelled = raster(&svg, fallback);
assert!(
unlabelled.iter().any(|&b| b != 0),
"the boxes and arrows are drawn either way — otherwise the comparison means nothing"
);
let (labelled_px, _) = labelled.as_chunks::<4>();
let (unlabelled_px, _) = unlabelled.as_chunks::<4>();
let changed = labelled_px
.iter()
.zip(unlabelled_px)
.filter(|(a, b)| a != b)
.count();
assert!(
changed > 100,
"the labels must put glyphs on the page: only {changed} pixels differ between the \
diagram drawn with the embedded face and the same diagram drawn with no font at all"
);
}
#[test]
fn the_fallback_stays_out_of_the_way_when_sans_serif_already_resolves() {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(
ratex_katex_fonts::ttf_bytes("KaTeX_Main-Regular.ttf")
.expect("the embedded set carries the main face")
.into_owned(),
);
let system_family = db
.faces()
.next()
.and_then(|f| f.families.first().map(|(name, _)| name.clone()))
.expect("the staged face registers a family");
db.set_sans_serif_family(system_family.clone());
let faces_before = db.len();
assert!(!install_fallback_sans_serif(&mut db), "must not engage");
assert_eq!(db.len(), faces_before, "no face may be registered");
assert_eq!(
db.family_name(&usvg::fontdb::Family::SansSerif),
system_family,
"the machine's own sans-serif mapping must be left exactly as it was"
);
}
#[test]
fn the_shared_db_adds_the_fallback_only_where_the_system_needs_it() {
let mut system = usvg::fontdb::Database::new();
system.load_system_fonts();
let shared = shared_fontdb();
if resolves_sans_serif(&system) {
assert_eq!(
shared.len(),
system.len(),
"a machine that resolves sans-serif gets no extra face"
);
assert_eq!(
shared.family_name(&usvg::fontdb::Family::SansSerif),
system.family_name(&usvg::fontdb::Family::SansSerif),
"and keeps its own generic mapping"
);
} else {
assert_eq!(
shared.len(),
system.len() + 1,
"a machine that does not gets exactly one embedded face"
);
assert!(
resolves_sans_serif(&shared),
"and sans-serif resolves after it"
);
}
}
#[test]
#[ignore = "asserts the host has fonts of its own; CI's Linux job runs it by name"]
fn the_system_resolves_sans_serif_without_the_fallback() {
let mut system = usvg::fontdb::Database::new();
system.load_system_fonts();
assert!(
resolves_sans_serif(&system),
"this host resolves the generic sans-serif family to nothing ({} faces loaded, \
generic points at {:?}), so konoma's embedded fallback would be doing the drawing",
system.len(),
system.family_name(&usvg::fontdb::Family::SansSerif),
);
}
#[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 も描ける"
);
}
}