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// All the embedded faces are used only by `load_embedded_fonts`, which is
19// itself `raster`-gated — so gate the fonts too, or a math-only build (the
20// wasm size split: `default-features = false, features = ["math"]`)
21// `include_bytes!`s ~hundreds of KB of TTFs it never reads (#288).
22#[cfg(feature = "raster")]
23const EMBEDDED_FONT: &[u8] = include_bytes!("../fonts/Go-Regular.ttf");
24#[cfg(feature = "raster")]
25const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../fonts/Go-Bold.ttf");
26#[cfg(feature = "raster")]
27const EMBEDDED_FONT_ITALIC: &[u8] = include_bytes!("../fonts/Go-Italic.ttf");
28#[cfg(feature = "raster")]
29const EMBEDDED_FONT_BOLD_ITALIC: &[u8] = include_bytes!("../fonts/Go-Bold-Italic.ttf");
30#[cfg(feature = "raster")]
31const EMBEDDED_FONT_MONO: &[u8] = include_bytes!("../fonts/Go-Mono.ttf");
32#[cfg(feature = "raster")]
33const EMBEDDED_FONT_MONO_BOLD: &[u8] = include_bytes!("../fonts/Go-Mono-Bold.ttf");
34/// The bundled font's internal family name.
35#[cfg(feature = "raster")]
36const EMBEDDED_FONT_FAMILY: &str = "Go";
37/// The bundled monospace family's internal name.
38#[cfg(feature = "raster")]
39const EMBEDDED_MONO_FAMILY: &str = "Go Mono";
40
41#[cfg(feature = "math")]
42pub mod math;
43
44/// Default maximum PNG raster dimension, in pixels, for either axis.
45#[cfg(feature = "raster")]
46pub const DEFAULT_MAX_RASTER_DIMENSION: u32 = 32_768;
47
48/// Default maximum PNG raster area, in pixels.
49#[cfg(feature = "raster")]
50pub const DEFAULT_MAX_RASTER_PIXELS: u64 = 64_000_000;
51
52/// Limits applied before allocating a PNG raster surface.
53#[cfg(feature = "raster")]
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct RasterLimits {
56    pub max_width: u32,
57    pub max_height: u32,
58    pub max_pixels: u64,
59}
60
61#[cfg(feature = "raster")]
62impl RasterLimits {
63    pub const fn new(max_width: u32, max_height: u32, max_pixels: u64) -> Self {
64        Self {
65            max_width,
66            max_height,
67            max_pixels,
68        }
69    }
70}
71
72#[cfg(feature = "raster")]
73impl Default for RasterLimits {
74    fn default() -> Self {
75        Self {
76            max_width: DEFAULT_MAX_RASTER_DIMENSION,
77            max_height: DEFAULT_MAX_RASTER_DIMENSION,
78            max_pixels: DEFAULT_MAX_RASTER_PIXELS,
79        }
80    }
81}
82
83/// Rasterize an SVG string to PNG bytes at the given scale (1.0 = 96 dpi, the
84/// SVG's native resolution).
85#[cfg(feature = "raster")]
86fn load_embedded_fonts(db: &mut resvg::usvg::fontdb::Database) {
87    for data in [
88        EMBEDDED_FONT,
89        EMBEDDED_FONT_BOLD,
90        EMBEDDED_FONT_ITALIC,
91        EMBEDDED_FONT_BOLD_ITALIC,
92        EMBEDDED_FONT_MONO,
93        EMBEDDED_FONT_MONO_BOLD,
94    ] {
95        db.load_font_data(data.to_vec());
96    }
97    db.set_serif_family(EMBEDDED_FONT_FAMILY);
98    db.set_sans_serif_family(EMBEDDED_FONT_FAMILY);
99    db.set_monospace_family(EMBEDDED_MONO_FAMILY);
100    db.set_cursive_family(EMBEDDED_FONT_FAMILY);
101    db.set_fantasy_family(EMBEDDED_FONT_FAMILY);
102}
103
104#[cfg(feature = "raster")]
105pub fn to_png(svg: &str, scale: f32) -> Result<Vec<u8>, String> {
106    to_png_with_limits(svg, scale, RasterLimits::default())
107}
108
109/// Rasterize an SVG string to PNG bytes with explicit raster allocation limits.
110#[cfg(feature = "raster")]
111pub fn to_png_with_limits(svg: &str, scale: f32, limits: RasterLimits) -> Result<Vec<u8>, String> {
112    use resvg::{tiny_skia, usvg};
113
114    if !scale.is_finite() || scale <= 0.0 {
115        return Err("scale must be a positive finite number".into());
116    }
117
118    let mut opt = usvg::Options::default();
119    load_embedded_fonts(opt.fontdb_mut());
120    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
121
122    let size = tree.size();
123    let (w, h) = checked_raster_size(size.width(), size.height(), scale, limits)?;
124    let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or("failed to allocate pixmap")?;
125    resvg::render(
126        &tree,
127        tiny_skia::Transform::from_scale(scale, scale),
128        &mut pixmap.as_mut(),
129    );
130    pixmap.encode_png().map_err(|e| e.to_string())
131}
132
133#[cfg(feature = "raster")]
134fn checked_raster_size(
135    svg_width: f32,
136    svg_height: f32,
137    scale: f32,
138    limits: RasterLimits,
139) -> Result<(u32, u32), String> {
140    let width = checked_scaled_dimension(svg_width, scale, "width")?;
141    let height = checked_scaled_dimension(svg_height, scale, "height")?;
142    let pixels = u64::from(width) * u64::from(height);
143
144    if width > limits.max_width || height > limits.max_height || pixels > limits.max_pixels {
145        return Err(format!(
146            "raster output exceeds configured pixel limit: {width}x{height} ({pixels} pixels) exceeds max {}x{} or {} pixels",
147            limits.max_width, limits.max_height, limits.max_pixels
148        ));
149    }
150
151    Ok((width, height))
152}
153
154#[cfg(feature = "raster")]
155fn checked_scaled_dimension(value: f32, scale: f32, axis: &str) -> Result<u32, String> {
156    let scaled = f64::from(value) * f64::from(scale);
157    if !scaled.is_finite() {
158        return Err(format!("raster {axis} is not finite"));
159    }
160
161    let pixels = scaled.ceil().max(1.0);
162    if pixels > f64::from(u32::MAX) {
163        return Err(format!(
164            "raster output exceeds configured pixel limit: scaled {axis} {pixels:e} exceeds u32::MAX"
165        ));
166    }
167
168    Ok(pixels as u32)
169}
170
171/// Convert an SVG string to PDF bytes.
172#[cfg(feature = "raster")]
173pub fn to_pdf(svg: &str) -> Result<Vec<u8>, String> {
174    use svg2pdf::usvg;
175
176    let mut opt = usvg::Options::default();
177    load_embedded_fonts(opt.fontdb_mut());
178    let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
179
180    svg2pdf::to_pdf(
181        &tree,
182        svg2pdf::ConversionOptions::default(),
183        svg2pdf::PageOptions::default(),
184    )
185    .map_err(|e| e.to_string())
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[cfg(feature = "raster")]
193    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>";
194    #[cfg(feature = "raster")]
195    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>";
196
197    #[cfg(feature = "raster")]
198    #[test]
199    fn png_has_magic_and_scales() {
200        let one = to_png(SVG, 1.0).unwrap();
201        assert_eq!(&one[..4], &[0x89, 0x50, 0x4E, 0x47]); // PNG signature
202        let two = to_png(SVG, 2.0).unwrap();
203        assert!(two.len() > one.len()); // larger raster at 2x
204    }
205
206    #[cfg(feature = "raster")]
207    #[test]
208    fn png_rejects_invalid_scale() {
209        assert!(to_png(SVG, 0.0).is_err());
210        assert!(to_png(SVG, f32::NAN).is_err());
211    }
212
213    #[cfg(feature = "raster")]
214    #[test]
215    fn png_rejects_huge_scale_before_allocation() {
216        let err = to_png(SVG, 100_000.0).unwrap_err();
217        assert!(err.contains("raster output exceeds configured pixel limit"));
218    }
219
220    #[cfg(feature = "raster")]
221    #[test]
222    fn png_respects_custom_raster_limits() {
223        let err = to_png_with_limits(SVG, 1.0, RasterLimits::new(100, 100, 100)).unwrap_err();
224        assert!(err.contains("40x20 (800 pixels)"), "{err}");
225
226        let png = to_png_with_limits(SVG, 1.0, RasterLimits::new(100, 100, 1_000)).unwrap();
227        assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]);
228    }
229
230    #[cfg(feature = "raster")]
231    #[test]
232    fn gradient_fill_rasterizes_offline() {
233        // The exact defs markup the rpic SVG backend emits for the `gradient`
234        // extension must rasterize in resvg and convert in svg2pdf, so PNG and
235        // PDF stay backend-stable with the SVG.
236        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>";
237        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>";
238        let grad = to_png(GRAD, 1.0).unwrap();
239        assert_eq!(&grad[..4], &[0x89, 0x50, 0x4E, 0x47]);
240        // a real gradient compresses differently from a flat fill — if resvg
241        // ignored the paint server, both rects would rasterize identically
242        let flat = to_png(FLAT, 1.0).unwrap();
243        assert_ne!(grad, flat);
244        assert_eq!(&to_pdf(GRAD).unwrap()[..4], b"%PDF");
245    }
246
247    #[cfg(feature = "math")]
248    #[test]
249    fn math_fragment_root_is_unitless_px() {
250        // RaTeX emits pt-suffixed root dimensions; the renderer must strip
251        // them so the fragment scales 1:1 with its metrics (1pt = 4/3 px
252        // would render formulas 33% larger than the layout box).
253        let span = math::render_math("x", 11.0).unwrap();
254        let head = &span.svg[..span.svg.find('>').unwrap()];
255        assert!(!head.contains("pt\""), "{head}");
256        // root width in px must match the metric width in inches * 96
257        let w_attr: f64 = head
258            .split("width=\"")
259            .nth(1)
260            .and_then(|t| t.split('\"').next())
261            .unwrap()
262            .parse()
263            .unwrap();
264        assert!(
265            (w_attr - span.width * 96.0).abs() < 0.01,
266            "{w_attr} vs {}",
267            span.width * 96.0
268        );
269    }
270
271    #[cfg(all(feature = "math", feature = "raster"))]
272    #[test]
273    fn texlabels_math_renders_through_png_and_pdf() {
274        // Full pipeline: RaTeX-typeset label -> nested SVG fragment ->
275        // rasterized by resvg / converted by svg2pdf. Pins that the exact
276        // markup the extension emits stays backend-stable.
277        rpic_core::set_math_renderer(math::render_math);
278        let src = "texlabels = 1\nbox \"$-\\frac{T}{2}$\" wid 1 ht 0.7";
279        let d = rpic_core::compile(src).unwrap();
280        let svg = rpic_core::to_svg(&d);
281        assert!(svg.contains("<svg x=\""), "{svg}");
282        assert!(!svg.contains("frac"), "raw TeX must not leak: {svg}");
283
284        let png = to_png(&svg, 2.0).unwrap();
285        assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]);
286        // the math glyphs must actually paint: materially larger than the
287        // same box with no label at all
288        let blank_d = rpic_core::compile("box wid 1 ht 0.7").unwrap();
289        let blank = to_png(&rpic_core::to_svg(&blank_d), 2.0).unwrap();
290        assert!(
291            png.len() > blank.len(),
292            "png {} <= blank {}",
293            png.len(),
294            blank.len()
295        );
296
297        assert_eq!(&to_pdf(&svg).unwrap()[..4], b"%PDF");
298    }
299
300    #[cfg(feature = "raster")]
301    #[test]
302    fn pdf_has_magic() {
303        let pdf = to_pdf(SVG).unwrap();
304        assert_eq!(&pdf[..4], b"%PDF");
305    }
306
307    #[cfg(feature = "raster")]
308    #[test]
309    fn bundled_font_rasterizes_text() {
310        // text must produce non-blank pixels using only the embedded font (no
311        // reliance on system fonts) — the text PNG is materially larger than an
312        // identically-sized blank one.
313        let with_text = to_png(SVG_TEXT, 1.0).unwrap();
314        let blank = to_png(
315            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\"></svg>",
316            1.0,
317        )
318        .unwrap();
319        assert!(
320            with_text.len() > blank.len() + 200,
321            "text PNG {} vs blank {}",
322            with_text.len(),
323            blank.len()
324        );
325    }
326}