1use crate::{MAX_PIXELS, Raster, RasterOptions};
3use resvg::{tiny_skia, usvg};
4use std::sync::{
5 Arc, OnceLock,
6 atomic::{AtomicBool, Ordering},
7};
8pub const MAX_SOURCE: usize = 256 * 1024;
10pub(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
34pub fn validate(source: &str) -> Result<(), Error> {
36 svg_preflight_inner(source, false).map(|_| ())
37}
38
39pub(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
85pub 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 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 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 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}