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;
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
29pub fn validate(source: &str) -> Result<(), Error> {
31 svg_preflight_inner(source, false).map(|_| ())
32}
33
34pub(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
80pub 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 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 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 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}