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