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