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 fonts (the SVG backend emits `font-family="sans-serif"` at the
16/// root; the rpic font attributes add `font-weight`/`font-style`/generic
17/// `monospace` per `<text>`, so the bold/italic/mono faces ship too).
18#[cfg(feature = "raster")]
19const EMBEDDED_FONT: &[u8] = include_bytes!("../fonts/Go-Regular.ttf");
20const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../fonts/Go-Bold.ttf");
21const EMBEDDED_FONT_ITALIC: &[u8] = include_bytes!("../fonts/Go-Italic.ttf");
22const EMBEDDED_FONT_BOLD_ITALIC: &[u8] = include_bytes!("../fonts/Go-Bold-Italic.ttf");
23const EMBEDDED_FONT_MONO: &[u8] = include_bytes!("../fonts/Go-Mono.ttf");
24const EMBEDDED_FONT_MONO_BOLD: &[u8] = include_bytes!("../fonts/Go-Mono-Bold.ttf");
25/// The bundled font's internal family name.
26#[cfg(feature = "raster")]
27const EMBEDDED_FONT_FAMILY: &str = "Go";
28/// The bundled monospace family's internal name.
29const EMBEDDED_MONO_FAMILY: &str = "Go Mono";
30
31#[cfg(feature = "math")]
32pub mod math;
33
34/// Rasterize an SVG string to PNG bytes at the given scale (1.0 = 96 dpi, the
35/// SVG's native resolution).
36#[cfg(feature = "raster")]
37fn load_embedded_fonts(db: &mut resvg::usvg::fontdb::Database) {
38    for data in [
39        EMBEDDED_FONT,
40        EMBEDDED_FONT_BOLD,
41        EMBEDDED_FONT_ITALIC,
42        EMBEDDED_FONT_BOLD_ITALIC,
43        EMBEDDED_FONT_MONO,
44        EMBEDDED_FONT_MONO_BOLD,
45    ] {
46        db.load_font_data(data.to_vec());
47    }
48    db.set_serif_family(EMBEDDED_FONT_FAMILY);
49    db.set_sans_serif_family(EMBEDDED_FONT_FAMILY);
50    db.set_monospace_family(EMBEDDED_MONO_FAMILY);
51    db.set_cursive_family(EMBEDDED_FONT_FAMILY);
52    db.set_fantasy_family(EMBEDDED_FONT_FAMILY);
53}
54
55#[cfg(feature = "raster")]
56pub fn to_png(svg: &str, scale: f32) -> Result<Vec<u8>, String> {
57    use resvg::{tiny_skia, usvg};
58
59    if !scale.is_finite() || scale <= 0.0 {
60        return Err("scale must be a positive finite number".into());
61    }
62
63    let mut opt = usvg::Options::default();
64    load_embedded_fonts(opt.fontdb_mut());
65    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
66
67    let size = tree.size();
68    let w = ((size.width() * scale).ceil() as u32).max(1);
69    let h = ((size.height() * scale).ceil() as u32).max(1);
70    let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or("failed to allocate pixmap")?;
71    resvg::render(
72        &tree,
73        tiny_skia::Transform::from_scale(scale, scale),
74        &mut pixmap.as_mut(),
75    );
76    pixmap.encode_png().map_err(|e| e.to_string())
77}
78
79/// Convert an SVG string to PDF bytes.
80#[cfg(feature = "raster")]
81pub fn to_pdf(svg: &str) -> Result<Vec<u8>, String> {
82    use svg2pdf::usvg;
83
84    let mut opt = usvg::Options::default();
85    load_embedded_fonts(opt.fontdb_mut());
86    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
87
88    svg2pdf::to_pdf(
89        &tree,
90        svg2pdf::ConversionOptions::default(),
91        svg2pdf::PageOptions::default(),
92    )
93    .map_err(|e| e.to_string())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[cfg(feature = "raster")]
101    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>";
102    #[cfg(feature = "raster")]
103    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>";
104
105    #[cfg(feature = "raster")]
106    #[test]
107    fn png_has_magic_and_scales() {
108        let one = to_png(SVG, 1.0).unwrap();
109        assert_eq!(&one[..4], &[0x89, 0x50, 0x4E, 0x47]); // PNG signature
110        let two = to_png(SVG, 2.0).unwrap();
111        assert!(two.len() > one.len()); // larger raster at 2x
112    }
113
114    #[cfg(feature = "raster")]
115    #[test]
116    fn png_rejects_invalid_scale() {
117        assert!(to_png(SVG, 0.0).is_err());
118        assert!(to_png(SVG, f32::NAN).is_err());
119    }
120
121    #[cfg(feature = "raster")]
122    #[test]
123    fn gradient_fill_rasterizes_offline() {
124        // The exact defs markup the rpic SVG backend emits for the `gradient`
125        // extension must rasterize in resvg and convert in svg2pdf, so PNG and
126        // PDF stay backend-stable with the SVG.
127        const GRAD: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" viewBox=\"0 0 40 20\"><defs><linearGradient id=\"grad0\" gradientUnits=\"objectBoundingBox\" x1=\"0\" y1=\"0.5\" x2=\"1\" y2=\"0.5\"><stop offset=\"0\" stop-color=\"black\"/><stop offset=\"1\" stop-color=\"white\"/></linearGradient></defs><rect x=\"2\" y=\"2\" width=\"36\" height=\"16\" fill=\"url(#grad0)\"/></svg>";
128        const FLAT: &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=\"gray\"/></svg>";
129        let grad = to_png(GRAD, 1.0).unwrap();
130        assert_eq!(&grad[..4], &[0x89, 0x50, 0x4E, 0x47]);
131        // a real gradient compresses differently from a flat fill — if resvg
132        // ignored the paint server, both rects would rasterize identically
133        let flat = to_png(FLAT, 1.0).unwrap();
134        assert_ne!(grad, flat);
135        assert_eq!(&to_pdf(GRAD).unwrap()[..4], b"%PDF");
136    }
137
138    #[cfg(feature = "math")]
139    #[test]
140    fn math_fragment_root_is_unitless_px() {
141        // RaTeX emits pt-suffixed root dimensions; the renderer must strip
142        // them so the fragment scales 1:1 with its metrics (1pt = 4/3 px
143        // would render formulas 33% larger than the layout box).
144        let span = math::render_math("x", 11.0).unwrap();
145        let head = &span.svg[..span.svg.find('>').unwrap()];
146        assert!(!head.contains("pt\""), "{head}");
147        // root width in px must match the metric width in inches * 96
148        let w_attr: f64 = head
149            .split("width=\"")
150            .nth(1)
151            .and_then(|t| t.split('\"').next())
152            .unwrap()
153            .parse()
154            .unwrap();
155        assert!(
156            (w_attr - span.width * 96.0).abs() < 0.01,
157            "{w_attr} vs {}",
158            span.width * 96.0
159        );
160    }
161
162    #[cfg(all(feature = "math", feature = "raster"))]
163    #[test]
164    fn texlabels_math_renders_through_png_and_pdf() {
165        // Full pipeline: RaTeX-typeset label -> nested SVG fragment ->
166        // rasterized by resvg / converted by svg2pdf. Pins that the exact
167        // markup the extension emits stays backend-stable.
168        rpic_core::set_math_renderer(math::render_math);
169        let src = "texlabels = 1\nbox \"$-\\frac{T}{2}$\" wid 1 ht 0.7";
170        let d = rpic_core::compile(src).unwrap();
171        let svg = rpic_core::to_svg(&d);
172        assert!(svg.contains("<svg x=\""), "{svg}");
173        assert!(!svg.contains("frac"), "raw TeX must not leak: {svg}");
174
175        let png = to_png(&svg, 2.0).unwrap();
176        assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]);
177        // the math glyphs must actually paint: materially larger than the
178        // same box with no label at all
179        let blank_d = rpic_core::compile("box wid 1 ht 0.7").unwrap();
180        let blank = to_png(&rpic_core::to_svg(&blank_d), 2.0).unwrap();
181        assert!(
182            png.len() > blank.len(),
183            "png {} <= blank {}",
184            png.len(),
185            blank.len()
186        );
187
188        assert_eq!(&to_pdf(&svg).unwrap()[..4], b"%PDF");
189    }
190
191    #[cfg(feature = "raster")]
192    #[test]
193    fn pdf_has_magic() {
194        let pdf = to_pdf(SVG).unwrap();
195        assert_eq!(&pdf[..4], b"%PDF");
196    }
197
198    #[cfg(feature = "raster")]
199    #[test]
200    fn bundled_font_rasterizes_text() {
201        // text must produce non-blank pixels using only the embedded font (no
202        // reliance on system fonts) — the text PNG is materially larger than an
203        // identically-sized blank one.
204        let with_text = to_png(SVG_TEXT, 1.0).unwrap();
205        let blank = to_png(
206            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\"></svg>",
207            1.0,
208        )
209        .unwrap();
210        assert!(
211            with_text.len() > blank.len() + 200,
212            "text PNG {} vs blank {}",
213            with_text.len(),
214            blank.len()
215        );
216    }
217}