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