Skip to main content

rpic_render/
lib.rs

1//! Raster (PNG) and PDF backends for rpic.
2//!
3//! These consume the SVG produced by `rpic-core` and convert it with pure-Rust
4//! libraries (no system dependencies), keeping `rpic-core` itself
5//! dependency-free and WASM-friendly.
6//!
7//! - PNG: parse the SVG with `usvg`, rasterize with `resvg`/`tiny-skia`.
8//! - PDF: parse with svg2pdf's `usvg`, convert with `svg2pdf`.
9//!
10//! Text is rendered with a **bundled** font (the Go font, BSD-3-Clause)
11//! registered as every default family, so attached labels rasterize identically
12//! on any machine — no dependency on which fonts happen to be installed. See
13//! `fonts/LICENSE`.
14
15/// Bundled font used for all text (the SVG backend emits `font-family="sans-serif"`).
16const EMBEDDED_FONT: &[u8] = include_bytes!("../fonts/Go-Regular.ttf");
17/// The bundled font's internal family name.
18const EMBEDDED_FONT_FAMILY: &str = "Go";
19
20/// Rasterize an SVG string to PNG bytes at the given scale (1.0 = 96 dpi, the
21/// SVG's native resolution).
22pub fn to_png(svg: &str, scale: f32) -> Result<Vec<u8>, String> {
23    use resvg::{tiny_skia, usvg};
24
25    if !scale.is_finite() || scale <= 0.0 {
26        return Err("scale must be a positive finite number".into());
27    }
28
29    let mut opt = usvg::Options::default();
30    {
31        let db = opt.fontdb_mut();
32        db.load_font_data(EMBEDDED_FONT.to_vec());
33        db.set_serif_family(EMBEDDED_FONT_FAMILY);
34        db.set_sans_serif_family(EMBEDDED_FONT_FAMILY);
35        db.set_monospace_family(EMBEDDED_FONT_FAMILY);
36        db.set_cursive_family(EMBEDDED_FONT_FAMILY);
37        db.set_fantasy_family(EMBEDDED_FONT_FAMILY);
38    }
39    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
40
41    let size = tree.size();
42    let w = ((size.width() * scale).ceil() as u32).max(1);
43    let h = ((size.height() * scale).ceil() as u32).max(1);
44    let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or("failed to allocate pixmap")?;
45    resvg::render(
46        &tree,
47        tiny_skia::Transform::from_scale(scale, scale),
48        &mut pixmap.as_mut(),
49    );
50    pixmap.encode_png().map_err(|e| e.to_string())
51}
52
53/// Convert an SVG string to PDF bytes.
54pub fn to_pdf(svg: &str) -> Result<Vec<u8>, String> {
55    use svg2pdf::usvg;
56
57    let mut opt = usvg::Options::default();
58    {
59        let db = opt.fontdb_mut();
60        db.load_font_data(EMBEDDED_FONT.to_vec());
61        db.set_serif_family(EMBEDDED_FONT_FAMILY);
62        db.set_sans_serif_family(EMBEDDED_FONT_FAMILY);
63        db.set_monospace_family(EMBEDDED_FONT_FAMILY);
64        db.set_cursive_family(EMBEDDED_FONT_FAMILY);
65        db.set_fantasy_family(EMBEDDED_FONT_FAMILY);
66    }
67    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
68
69    svg2pdf::to_pdf(
70        &tree,
71        svg2pdf::ConversionOptions::default(),
72        svg2pdf::PageOptions::default(),
73    )
74    .map_err(|e| e.to_string())
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    const SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" viewBox=\"0 0 40 20\"><rect x=\"2\" y=\"2\" width=\"36\" height=\"16\" fill=\"none\" stroke=\"black\"/></svg>";
82    const SVG_TEXT: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\" font-family=\"sans-serif\" font-size=\"16\"><text x=\"4\" y=\"24\">Hello world</text></svg>";
83
84    #[test]
85    fn png_has_magic_and_scales() {
86        let one = to_png(SVG, 1.0).unwrap();
87        assert_eq!(&one[..4], &[0x89, 0x50, 0x4E, 0x47]); // PNG signature
88        let two = to_png(SVG, 2.0).unwrap();
89        assert!(two.len() > one.len()); // larger raster at 2x
90    }
91
92    #[test]
93    fn png_rejects_invalid_scale() {
94        assert!(to_png(SVG, 0.0).is_err());
95        assert!(to_png(SVG, f32::NAN).is_err());
96    }
97
98    #[test]
99    fn pdf_has_magic() {
100        let pdf = to_pdf(SVG).unwrap();
101        assert_eq!(&pdf[..4], b"%PDF");
102    }
103
104    #[test]
105    fn bundled_font_rasterizes_text() {
106        // text must produce non-blank pixels using only the embedded font (no
107        // reliance on system fonts) — the text PNG is materially larger than an
108        // identically-sized blank one.
109        let with_text = to_png(SVG_TEXT, 1.0).unwrap();
110        let blank = to_png(
111            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\"></svg>",
112            1.0,
113        )
114        .unwrap();
115        assert!(
116            with_text.len() > blank.len() + 200,
117            "text PNG {} vs blank {}",
118            with_text.len(),
119            blank.len()
120        );
121    }
122}