Skip to main content

document_graphics/
svg.rs

1//! Restricted SVG rendering with bundled fonts and no external resources.
2use crate::{MAX_PIXELS, Raster, RasterOptions};
3use resvg::{tiny_skia, usvg};
4use std::sync::{
5    Arc, OnceLock,
6    atomic::{AtomicBool, Ordering},
7};
8/// Maximum encoded SVG source size, before parsing or font loading.
9pub const MAX_SOURCE: usize = 256 * 1024;
10static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
11#[derive(Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum Error {
14    Invalid,
15    Unsupported,
16    Limit,
17}
18impl std::fmt::Display for Error {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        f.write_str(match self {
21            Self::Invalid => "Invalid SVG",
22            Self::Unsupported => "Unsupported SVG content or font",
23            Self::Limit => "SVG exceeds preview limits",
24        })
25    }
26}
27impl std::error::Error for Error {}
28
29/// Validate local SVG input without loading fonts or allocating pixels.
30pub fn validate(source: &str) -> Result<(), Error> {
31    svg_preflight_inner(source, false).map(|_| ())
32}
33
34// Reuse the same embedded faces for SVG shaping and Mermaid measurement.
35pub(crate) fn fonts(extended: bool) -> &'static Arc<usvg::fontdb::Database> {
36    let base = FONTS.get_or_init(|| {
37        let mut database = usvg::fontdb::Database::new();
38        for font in katex_native::FONTS.iter().filter(|font| {
39            matches!(
40                font.font.family,
41                katex_native::Family::Main
42                    | katex_native::Family::SansSerif
43                    | katex_native::Family::Typewriter
44            )
45        }) {
46            let ids = database.load_font_source(usvg::fontdb::Source::Binary(Arc::new(font.data)));
47            for id in ids {
48                if let Some(mut face) = database.face(id).cloned() {
49                    face.style = if font.font.italic {
50                        usvg::fontdb::Style::Italic
51                    } else {
52                        usvg::fontdb::Style::Normal
53                    };
54                    database.remove_face(id);
55                    database.push_face_info(face);
56                }
57            }
58        }
59        database.set_serif_family("KaTeX_Main");
60        database.set_sans_serif_family("KaTeX_SansSerif");
61        database.set_monospace_family("KaTeX_Typewriter");
62        Arc::new(database)
63    });
64    if !extended {
65        return base;
66    }
67    static EXTENDED: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
68    EXTENDED.get_or_init(|| {
69        let mut database = (**base).clone();
70        for bytes in [
71            include_bytes!("../fonts/NotoSans-Regular.ttf").as_slice(),
72            include_bytes!("../fonts/NotoSans-Bold.ttf").as_slice(),
73        ] {
74            database.load_font_source(usvg::fontdb::Source::Binary(Arc::new(bytes)));
75        }
76        Arc::new(database)
77    })
78}
79
80/// Rasterize local SVG using bounded geometry and bundled text fonts. Images,
81/// filters, reuse and markers are unsupported; missing glyphs return an error.
82pub fn rasterize(source: &str, options: RasterOptions) -> Result<Raster, Error> {
83    rasterize_inner(source, options, false)
84}
85pub(crate) fn diagram(source: &str, options: RasterOptions) -> Result<Raster, Error> {
86    rasterize_inner(source, options, true)
87}
88fn rasterize_inner(source: &str, fitting: RasterOptions, markers: bool) -> Result<Raster, Error> {
89    if !fitting.valid() {
90        return Err(Error::Limit);
91    }
92    let (has_text, extended_fonts) = svg_preflight_inner(source, markers)?;
93    let missing_font = Arc::new(AtomicBool::new(false));
94    let mut options = usvg::Options {
95        image_href_resolver: usvg::ImageHrefResolver {
96            resolve_string: Box::new(|_, _| None),
97            resolve_data: Box::new(|_, _, _| None),
98        },
99        font_family: "KaTeX_Main".into(),
100        ..Default::default()
101    };
102    if has_text {
103        options.fontdb = fonts(extended_fonts).clone();
104        let select = usvg::FontResolver::default_font_selector();
105        let missing = missing_font.clone();
106        options.font_resolver.select_font = Box::new(move |font, database| {
107            let selected = select(font, database);
108            if selected.is_none() {
109                missing.store(true, Ordering::Relaxed);
110            }
111            selected
112        });
113        let select = usvg::FontResolver::default_fallback_selector();
114        let missing = missing_font.clone();
115        options.font_resolver.select_fallback = Box::new(move |character, excluded, database| {
116            let selected = select(character, excluded, database);
117            if selected.is_none() {
118                missing.store(true, Ordering::Relaxed);
119            }
120            selected
121        });
122    }
123    let tree = usvg::Tree::from_str(source, &options).map_err(|_| Error::Invalid)?;
124    if missing_font.load(Ordering::Relaxed) {
125        return Err(Error::Unsupported);
126    }
127    let size = tree.size();
128    let original = (size.width().ceil() as u32, size.height().ceil() as u32);
129    if original.0 == 0
130        || original.1 == 0
131        || original.0 > 8192
132        || original.1 > 8192
133        || u64::from(original.0) * u64::from(original.1) > 4_000_000
134    {
135        return Err(Error::Limit);
136    }
137    // Logical dimensions are stable even when fitting reduces physical pixels.
138    let scale = fitting
139        .scale
140        .min(f64::from(fitting.bounds.0) / f64::from(original.0))
141        .min(f64::from(fitting.bounds.1) / f64::from(original.1))
142        .min((MAX_PIXELS as f64 / (f64::from(original.0) * f64::from(original.1))).sqrt());
143    let (width, height) = (
144        (f64::from(original.0) * scale).floor().max(1.) as u32,
145        (f64::from(original.1) * scale).floor().max(1.) as u32,
146    );
147    let mut pixels = tiny_skia::Pixmap::new(width, height).ok_or(Error::Limit)?;
148    resvg::render(
149        &tree,
150        tiny_skia::Transform::from_scale(
151            width as f32 / size.width(),
152            height as f32 / size.height(),
153        ),
154        &mut pixels.as_mut(),
155    );
156    // Consumers use straight alpha; tiny-skia produces premultiplied pixels.
157    let rgba = pixels
158        .pixels()
159        .iter()
160        .flat_map(|pixel| {
161            let pixel = pixel.demultiply();
162            [pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()]
163        })
164        .collect();
165    Ok(Raster {
166        original_width: original.0,
167        original_height: original.1,
168        width,
169        height,
170        rgba,
171    })
172}
173fn svg_preflight_inner(source: &str, markers: bool) -> Result<(bool, bool), Error> {
174    if source.len() > MAX_SOURCE {
175        return Err(Error::Limit);
176    }
177    let document = roxmltree::Document::parse_with_options(
178        source,
179        roxmltree::ParsingOptions {
180            allow_dtd: false,
181            nodes_limit: 4096,
182        },
183    )
184    .map_err(|_| Error::Invalid)?;
185    if document.root_element().tag_name().name() != "svg" {
186        return Err(Error::Invalid);
187    }
188    let mut extended_fonts = false;
189    let (mut geometry, mut text_bytes, mut has_text, mut marker_count) = (0, 0, false, 0);
190    for node in document.descendants() {
191        if node.ancestors().take(34).count() > 33 {
192            return Err(Error::Limit);
193        }
194        if node.is_text() {
195            let text = node.text().unwrap_or_default();
196            text_bytes += text.len();
197            extended_fonts |= !text.is_ascii();
198        }
199        if text_bytes > 32 * 1024 {
200            return Err(Error::Limit);
201        }
202        if !node.is_element() {
203            continue;
204        }
205        let name = node.tag_name().name();
206        if name == "marker" && markers {
207            marker_count += 1;
208            // Arrow markers cannot recursively reference markers or paint servers.
209            if marker_count > 16 || node.descendants().count() > 8 || node.range().len() > 2048 {
210                return Err(Error::Limit);
211            }
212            for child in node.descendants() {
213                for attr in child.attributes() {
214                    if attr.value().to_ascii_lowercase().contains("url(") {
215                        return Err(Error::Unsupported);
216                    }
217                }
218            }
219        } else if !matches!(
220            name,
221            "svg"
222                | "g"
223                | "defs"
224                | "title"
225                | "desc"
226                | "path"
227                | "rect"
228                | "circle"
229                | "ellipse"
230                | "line"
231                | "polyline"
232                | "polygon"
233                | "linearGradient"
234                | "radialGradient"
235                | "stop"
236                | "clipPath"
237                | "text"
238                | "tspan"
239                | "textPath"
240                | "style"
241        ) {
242            return Err(Error::Unsupported);
243        }
244        has_text |= name == "text";
245        if name == "style" {
246            local_css(node.text().unwrap_or_default())?;
247        }
248        for attr in node.attributes() {
249            extended_fonts |= attr.name() == "font-family" && attr.value().contains("Noto Sans");
250            if matches!(attr.name(), "d" | "points") {
251                geometry += attr.value().len();
252            }
253            if attr.name() == "href" && !attr.value().starts_with('#') {
254                return Err(Error::Unsupported);
255            }
256            local_css(attr.value())?;
257        }
258        if geometry > 64 * 1024 {
259            return Err(Error::Limit);
260        }
261    }
262    Ok((has_text, extended_fonts))
263}
264
265fn local_css(value: &str) -> Result<(), Error> {
266    let value = value.to_ascii_lowercase();
267    if value.contains('\\') || value.contains("@import") || value.contains("@font-face") {
268        return Err(Error::Unsupported);
269    }
270    for suffix in value.split("url(").skip(1) {
271        let reference = suffix
272            .split(')')
273            .next()
274            .unwrap_or_default()
275            .trim()
276            .trim_matches(['\'', '"']);
277        if !reference.starts_with('#') {
278            return Err(Error::Unsupported);
279        }
280    }
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    fn rasterize(source: &str) -> Result<Raster, Error> {
288        super::diagram(source, RasterOptions::scaled(2.))
289    }
290    #[test]
291    fn unicode_text_and_numeric_entities_use_the_same_embedded_glyphs() {
292        let source = "<svg xmlns='http://www.w3.org/2000/svg' width='180' height='35'><text x='2' y='24' font-size='20'>Café Ελληνικά</text></svg>";
293        let literal = rasterize(source).unwrap();
294        let entities = source
295            .chars()
296            .map(|c| {
297                if c.is_ascii() {
298                    c.to_string()
299                } else {
300                    format!("&#{};", c as u32)
301                }
302            })
303            .collect::<String>();
304        assert_eq!(literal.rgba, rasterize(&entities).unwrap().rgba);
305        assert_eq!(fonts(true).len(), fonts(false).len() + 2);
306    }
307
308    #[test]
309    fn caller_fitting_is_bounded_and_local_svg_cannot_enable_diagram_markers() {
310        let shape = "<svg xmlns='http://www.w3.org/2000/svg' width='200' height='100'><rect width='200' height='100' fill='red' opacity='0.5'/></svg>";
311        for (options, dimensions) in [
312            (RasterOptions::fit((80, 80)), (80, 40)),
313            (RasterOptions::fit((500, 500)), (200, 100)),
314            (RasterOptions::scaled(2.), (400, 200)),
315        ] {
316            let p = super::rasterize(shape, options).unwrap();
317            assert_eq!((p.width, p.height), dimensions);
318            assert_eq!((p.original_width, p.original_height), (200, 100));
319            assert_eq!(&p.rgba[..4], &[255, 0, 0, 128]);
320        }
321        for options in [
322            RasterOptions::fit((0, 20)),
323            RasterOptions::fit((8193, 20)),
324            RasterOptions::scaled(f64::NAN),
325            RasterOptions::scaled(f64::INFINITY),
326            RasterOptions::scaled(-1.),
327            RasterOptions::scaled(5.),
328        ] {
329            assert!(matches!(
330                super::rasterize(shape, options),
331                Err(Error::Limit)
332            ));
333        }
334        let marker = "<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'><defs><marker id='arrow'><path d='M0 0 L2 2'/></marker></defs></svg>";
335        assert!(matches!(
336            super::rasterize(marker, RasterOptions::fit((20, 20))),
337            Err(Error::Unsupported)
338        ));
339        assert!(super::diagram(marker, RasterOptions::fit((20, 20))).is_ok());
340        let text = "<svg xmlns='http://www.w3.org/2000/svg' width='80' height='20'><text y='16'>Test</text></svg>";
341        assert!(super::rasterize(text, RasterOptions::fit((100, 100))).is_ok());
342        assert!(FONTS.get().unwrap().len() <= 8);
343        assert!(matches!(
344            super::rasterize(
345                &text.replace("Test", "中文"),
346                RasterOptions::fit((100, 100))
347            ),
348            Err(Error::Unsupported)
349        ));
350    }
351    #[test]
352    fn generated_svg_cannot_load_resources_or_expand_references() {
353        for body in [
354            "<image href='file:///tmp/private.png'/>",
355            "<use href='#a'/>",
356            "<script>alert(1)</script>",
357            "<foreignObject/>",
358            "<filter id='f'/>",
359            "<style>@import 'https://example.com/a.css'</style>",
360            "<path fill='url(https://example.com/x)'/>",
361            "<text href='file:///tmp/private.txt'>label</text>",
362            "<marker id='m'><path marker-end='url(#m)'/></marker>",
363        ] {
364            let source = format!(
365                "<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'>{body}</svg>"
366            );
367            assert!(rasterize(&source).is_err(), "Accepted: {body}");
368        }
369        assert!(svg_preflight_inner(&format!("<svg>{}</svg>", "<g/>".repeat(5000)), true).is_err());
370        assert!(
371            svg_preflight_inner(
372                &format!("<svg>{}{}</svg>", "<g>".repeat(40), "</g>".repeat(40)),
373                true
374            )
375            .is_err()
376        );
377    }
378    #[test]
379    fn pixel_cap_preserves_logical_dimensions() {
380        let p = rasterize("<svg xmlns='http://www.w3.org/2000/svg' width='2000' height='1000'><rect width='2000' height='1000' fill='red'/></svg>").unwrap();
381        assert_eq!((p.original_width, p.original_height), (2000, 1000));
382        assert!(p.width * p.height <= crate::MAX_PIXELS as u32);
383        assert!(p.width < p.original_width);
384        assert!(
385            rasterize("<svg xmlns='http://www.w3.org/2000/svg' width='9000' height='1000'/>")
386                .is_err()
387        );
388    }
389}