leaf_raster/load.rs
1//! Decoding image files — raster formats through the `image` codecs, SVG
2//! through resvg — to the `DynamicImage` every consumer's pipeline speaks.
3
4use std::path::Path;
5
6/// Decode an image file to a `DynamicImage`, or `None` on any failure (missing,
7/// unreadable, or a format no decoder covers). SVG is rasterized with resvg
8/// ([`load_svg`]); every raster format goes through the `image` codec crate.
9pub fn load_image(path: &Path) -> Option<image::DynamicImage> {
10 if path
11 .extension()
12 .is_some_and(|e| e.eq_ignore_ascii_case("svg"))
13 {
14 return load_svg(&std::fs::read(path).ok()?);
15 }
16 image::ImageReader::open(path)
17 .ok()?
18 .with_guessed_format()
19 .ok()?
20 .decode()
21 .ok()
22}
23
24/// Rasterize an SVG's bytes to a `DynamicImage`, or `None` if it won't parse.
25///
26/// The `image` crate has no SVG support, so this is the vector path: usvg parses
27/// the document, resvg paints it onto a tiny-skia pixmap, and we hand the pixels
28/// back as an `RgbaImage` the rest of the pipeline treats like any decoded
29/// raster. We render at a fixed target resolution (scaling the SVG's own size so
30/// its longer side is ~[`SVG_TARGET_PX`]) rather than its intrinsic size: an SVG
31/// may declare a tiny viewport, and rasterizing that small would leave the
32/// consumer upscaling a blurry thumbnail. System fonts are loaded so an SVG that
33/// draws real `<text>` (not outlined paths) still renders its glyphs.
34pub fn load_svg(data: &[u8]) -> Option<image::DynamicImage> {
35 use resvg::{tiny_skia, usvg};
36
37 /// The longer side, in pixels, we rasterize an SVG to before the consumer
38 /// downscales it — big enough to stay crisp, capped so a huge viewport can't
39 /// blow up the allocation.
40 const SVG_TARGET_PX: f32 = 640.0;
41
42 // The system font set, enumerated once per process rather than once per
43 // SVG — loading it is tens of milliseconds of directory walking, and it
44 // runs on the render path. usvg shares the database by `Arc`, so every
45 // decode after the first borrows the same one.
46 fn svg_fontdb() -> std::sync::Arc<resvg::usvg::fontdb::Database> {
47 use std::sync::{Arc, OnceLock};
48 static FONTS: OnceLock<Arc<resvg::usvg::fontdb::Database>> = OnceLock::new();
49 FONTS
50 .get_or_init(|| {
51 let mut db = resvg::usvg::fontdb::Database::new();
52 db.load_system_fonts();
53 Arc::new(db)
54 })
55 .clone()
56 }
57
58 let opt = usvg::Options {
59 fontdb: svg_fontdb(),
60 ..Default::default()
61 };
62 let tree = usvg::Tree::from_data(data, &opt).ok()?;
63
64 let size = tree.size();
65 let longest = size.width().max(size.height()).max(1.0);
66 // Scale so the longer side hits the target; clamp so a big SVG scales down
67 // and a small one up, but neither runs away. Never below 1px per side.
68 let scale = (SVG_TARGET_PX / longest).clamp(0.05, 16.0);
69 let w = (size.width() * scale).ceil().max(1.0) as u32;
70 let h = (size.height() * scale).ceil().max(1.0) as u32;
71
72 let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
73 resvg::render(
74 &tree,
75 tiny_skia::Transform::from_scale(scale, scale),
76 &mut pixmap.as_mut(),
77 );
78
79 // tiny-skia stores premultiplied alpha; `image` expects straight alpha, so
80 // demultiply each pixel on the way into the RGBA buffer.
81 let mut rgba = Vec::with_capacity((w as usize) * (h as usize) * 4);
82 for px in pixmap.pixels() {
83 let c = px.demultiply();
84 rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
85 }
86 Some(image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(
87 w, h, rgba,
88 )?))
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn load_svg_rasterizes_to_straight_alpha_rgba() {
97 // A 20×10 solid-red rect. `image` can't decode SVG at all, so this only
98 // works via the resvg path.
99 let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#ff0000"/></svg>"##;
100 let img = load_svg(svg).expect("valid SVG should rasterize");
101 // Rendered at the target resolution, so upscaled from its 20×10 viewport
102 // while keeping the 2:1 aspect.
103 assert!(
104 img.width() >= 20 && img.height() >= 10,
105 "got {}×{}",
106 img.width(),
107 img.height()
108 );
109 assert_eq!(img.width(), img.height() * 2, "aspect ratio preserved");
110 // The fill lands as opaque, straight-alpha red — not premultiplied mush.
111 let rgba = img.to_rgba8();
112 let center = rgba.get_pixel(rgba.width() / 2, rgba.height() / 2).0;
113 assert_eq!(center, [255, 0, 0, 255], "center pixel is opaque red");
114 }
115
116 #[test]
117 fn load_svg_rejects_garbage() {
118 assert!(load_svg(b"not an svg at all").is_none());
119 }
120}