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 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 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 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 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}