use crate::{MAX_PIXELS, Raster, RasterOptions};
use resvg::{tiny_skia, usvg};
use std::sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
};
pub const MAX_SOURCE: usize = 256 * 1024;
static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
#[derive(Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
Invalid,
Unsupported,
Limit,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Invalid => "Invalid SVG",
Self::Unsupported => "Unsupported SVG content or font",
Self::Limit => "SVG exceeds preview limits",
})
}
}
impl std::error::Error for Error {}
pub fn validate(source: &str) -> Result<(), Error> {
svg_preflight_inner(source, false).map(|_| ())
}
pub(crate) fn fonts(extended: bool) -> &'static Arc<usvg::fontdb::Database> {
let base = FONTS.get_or_init(|| {
let mut database = usvg::fontdb::Database::new();
for font in katex_native::FONTS.iter().filter(|font| {
matches!(
font.font.family,
katex_native::Family::Main
| katex_native::Family::SansSerif
| katex_native::Family::Typewriter
)
}) {
let ids = database.load_font_source(usvg::fontdb::Source::Binary(Arc::new(font.data)));
for id in ids {
if let Some(mut face) = database.face(id).cloned() {
face.style = if font.font.italic {
usvg::fontdb::Style::Italic
} else {
usvg::fontdb::Style::Normal
};
database.remove_face(id);
database.push_face_info(face);
}
}
}
database.set_serif_family("KaTeX_Main");
database.set_sans_serif_family("KaTeX_SansSerif");
database.set_monospace_family("KaTeX_Typewriter");
Arc::new(database)
});
if !extended {
return base;
}
static EXTENDED: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
EXTENDED.get_or_init(|| {
let mut database = (**base).clone();
for bytes in [
include_bytes!("../fonts/NotoSans-Regular.ttf").as_slice(),
include_bytes!("../fonts/NotoSans-Bold.ttf").as_slice(),
] {
database.load_font_source(usvg::fontdb::Source::Binary(Arc::new(bytes)));
}
Arc::new(database)
})
}
pub fn rasterize(source: &str, options: RasterOptions) -> Result<Raster, Error> {
rasterize_inner(source, options, false)
}
pub(crate) fn diagram(source: &str, options: RasterOptions) -> Result<Raster, Error> {
rasterize_inner(source, options, true)
}
fn rasterize_inner(source: &str, fitting: RasterOptions, markers: bool) -> Result<Raster, Error> {
if !fitting.valid() {
return Err(Error::Limit);
}
let (has_text, extended_fonts) = svg_preflight_inner(source, markers)?;
let missing_font = Arc::new(AtomicBool::new(false));
let mut options = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver {
resolve_string: Box::new(|_, _| None),
resolve_data: Box::new(|_, _, _| None),
},
font_family: "KaTeX_Main".into(),
..Default::default()
};
if has_text {
options.fontdb = fonts(extended_fonts).clone();
let select = usvg::FontResolver::default_font_selector();
let missing = missing_font.clone();
options.font_resolver.select_font = Box::new(move |font, database| {
let selected = select(font, database);
if selected.is_none() {
missing.store(true, Ordering::Relaxed);
}
selected
});
let select = usvg::FontResolver::default_fallback_selector();
let missing = missing_font.clone();
options.font_resolver.select_fallback = Box::new(move |character, excluded, database| {
let selected = select(character, excluded, database);
if selected.is_none() {
missing.store(true, Ordering::Relaxed);
}
selected
});
}
let tree = usvg::Tree::from_str(source, &options).map_err(|_| Error::Invalid)?;
if missing_font.load(Ordering::Relaxed) {
return Err(Error::Unsupported);
}
let size = tree.size();
let original = (size.width().ceil() as u32, size.height().ceil() as u32);
if original.0 == 0
|| original.1 == 0
|| original.0 > 8192
|| original.1 > 8192
|| u64::from(original.0) * u64::from(original.1) > 4_000_000
{
return Err(Error::Limit);
}
let scale = fitting
.scale
.min(f64::from(fitting.bounds.0) / f64::from(original.0))
.min(f64::from(fitting.bounds.1) / f64::from(original.1))
.min((MAX_PIXELS as f64 / (f64::from(original.0) * f64::from(original.1))).sqrt());
let (width, height) = (
(f64::from(original.0) * scale).floor().max(1.) as u32,
(f64::from(original.1) * scale).floor().max(1.) as u32,
);
let mut pixels = tiny_skia::Pixmap::new(width, height).ok_or(Error::Limit)?;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(
width as f32 / size.width(),
height as f32 / size.height(),
),
&mut pixels.as_mut(),
);
let rgba = pixels
.pixels()
.iter()
.flat_map(|pixel| {
let pixel = pixel.demultiply();
[pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()]
})
.collect();
Ok(Raster {
original_width: original.0,
original_height: original.1,
width,
height,
rgba,
})
}
fn svg_preflight_inner(source: &str, markers: bool) -> Result<(bool, bool), Error> {
if source.len() > MAX_SOURCE {
return Err(Error::Limit);
}
let document = roxmltree::Document::parse_with_options(
source,
roxmltree::ParsingOptions {
allow_dtd: false,
nodes_limit: 4096,
},
)
.map_err(|_| Error::Invalid)?;
if document.root_element().tag_name().name() != "svg" {
return Err(Error::Invalid);
}
let mut extended_fonts = false;
let (mut geometry, mut text_bytes, mut has_text, mut marker_count) = (0, 0, false, 0);
for node in document.descendants() {
if node.ancestors().take(34).count() > 33 {
return Err(Error::Limit);
}
if node.is_text() {
let text = node.text().unwrap_or_default();
text_bytes += text.len();
extended_fonts |= !text.is_ascii();
}
if text_bytes > 32 * 1024 {
return Err(Error::Limit);
}
if !node.is_element() {
continue;
}
let name = node.tag_name().name();
if name == "marker" && markers {
marker_count += 1;
if marker_count > 16 || node.descendants().count() > 8 || node.range().len() > 2048 {
return Err(Error::Limit);
}
for child in node.descendants() {
for attr in child.attributes() {
if attr.value().to_ascii_lowercase().contains("url(") {
return Err(Error::Unsupported);
}
}
}
} else if !matches!(
name,
"svg"
| "g"
| "defs"
| "title"
| "desc"
| "path"
| "rect"
| "circle"
| "ellipse"
| "line"
| "polyline"
| "polygon"
| "linearGradient"
| "radialGradient"
| "stop"
| "clipPath"
| "text"
| "tspan"
| "textPath"
| "style"
) {
return Err(Error::Unsupported);
}
has_text |= name == "text";
if name == "style" {
local_css(node.text().unwrap_or_default())?;
}
for attr in node.attributes() {
extended_fonts |= attr.name() == "font-family" && attr.value().contains("Noto Sans");
if matches!(attr.name(), "d" | "points") {
geometry += attr.value().len();
}
if attr.name() == "href" && !attr.value().starts_with('#') {
return Err(Error::Unsupported);
}
local_css(attr.value())?;
}
if geometry > 64 * 1024 {
return Err(Error::Limit);
}
}
Ok((has_text, extended_fonts))
}
fn local_css(value: &str) -> Result<(), Error> {
let value = value.to_ascii_lowercase();
if value.contains('\\') || value.contains("@import") || value.contains("@font-face") {
return Err(Error::Unsupported);
}
for suffix in value.split("url(").skip(1) {
let reference = suffix
.split(')')
.next()
.unwrap_or_default()
.trim()
.trim_matches(['\'', '"']);
if !reference.starts_with('#') {
return Err(Error::Unsupported);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn rasterize(source: &str) -> Result<Raster, Error> {
super::diagram(source, RasterOptions::scaled(2.))
}
#[test]
fn unicode_text_and_numeric_entities_use_the_same_embedded_glyphs() {
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>";
let literal = rasterize(source).unwrap();
let entities = source
.chars()
.map(|c| {
if c.is_ascii() {
c.to_string()
} else {
format!("&#{};", c as u32)
}
})
.collect::<String>();
assert_eq!(literal.rgba, rasterize(&entities).unwrap().rgba);
assert_eq!(fonts(true).len(), fonts(false).len() + 2);
}
#[test]
fn caller_fitting_is_bounded_and_local_svg_cannot_enable_diagram_markers() {
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>";
for (options, dimensions) in [
(RasterOptions::fit((80, 80)), (80, 40)),
(RasterOptions::fit((500, 500)), (200, 100)),
(RasterOptions::scaled(2.), (400, 200)),
] {
let p = super::rasterize(shape, options).unwrap();
assert_eq!((p.width, p.height), dimensions);
assert_eq!((p.original_width, p.original_height), (200, 100));
assert_eq!(&p.rgba[..4], &[255, 0, 0, 128]);
}
for options in [
RasterOptions::fit((0, 20)),
RasterOptions::fit((8193, 20)),
RasterOptions::scaled(f64::NAN),
RasterOptions::scaled(f64::INFINITY),
RasterOptions::scaled(-1.),
RasterOptions::scaled(5.),
] {
assert!(matches!(
super::rasterize(shape, options),
Err(Error::Limit)
));
}
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>";
assert!(matches!(
super::rasterize(marker, RasterOptions::fit((20, 20))),
Err(Error::Unsupported)
));
assert!(super::diagram(marker, RasterOptions::fit((20, 20))).is_ok());
let text = "<svg xmlns='http://www.w3.org/2000/svg' width='80' height='20'><text y='16'>Test</text></svg>";
assert!(super::rasterize(text, RasterOptions::fit((100, 100))).is_ok());
assert!(FONTS.get().unwrap().len() <= 8);
assert!(matches!(
super::rasterize(
&text.replace("Test", "中文"),
RasterOptions::fit((100, 100))
),
Err(Error::Unsupported)
));
}
#[test]
fn generated_svg_cannot_load_resources_or_expand_references() {
for body in [
"<image href='file:///tmp/private.png'/>",
"<use href='#a'/>",
"<script>alert(1)</script>",
"<foreignObject/>",
"<filter id='f'/>",
"<style>@import 'https://example.com/a.css'</style>",
"<path fill='url(https://example.com/x)'/>",
"<text href='file:///tmp/private.txt'>label</text>",
"<marker id='m'><path marker-end='url(#m)'/></marker>",
] {
let source = format!(
"<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'>{body}</svg>"
);
assert!(rasterize(&source).is_err(), "Accepted: {body}");
}
assert!(svg_preflight_inner(&format!("<svg>{}</svg>", "<g/>".repeat(5000)), true).is_err());
assert!(
svg_preflight_inner(
&format!("<svg>{}{}</svg>", "<g>".repeat(40), "</g>".repeat(40)),
true
)
.is_err()
);
}
#[test]
fn pixel_cap_preserves_logical_dimensions() {
let p = rasterize("<svg xmlns='http://www.w3.org/2000/svg' width='2000' height='1000'><rect width='2000' height='1000' fill='red'/></svg>").unwrap();
assert_eq!((p.original_width, p.original_height), (2000, 1000));
assert!(p.width * p.height <= crate::MAX_PIXELS as u32);
assert!(p.width < p.original_width);
assert!(
rasterize("<svg xmlns='http://www.w3.org/2000/svg' width='9000' height='1000'/>")
.is_err()
);
}
}