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