katana_render_runtime/markdown/
svg_rasterize.rs1use resvg::{render, usvg};
6use tiny_skia::Pixmap;
7
8const MAX_RASTERIZED_SVG_EDGE: f32 = 8192.0;
9const LIGHT_DARK_FUNCTION: &str = "light-dark(";
10
11#[derive(Debug, Clone)]
12pub struct RasterizedSvg {
13 pub width: u32,
14 pub height: u32,
15 pub display_width: f32,
16 pub display_height: f32,
17 pub rgba: Vec<u8>,
18}
19
20pub struct SvgRasterizeOps;
21
22impl SvgRasterizeOps {
23 pub fn preprocess_for_rasterizer(svg_text: &str) -> String {
24 let with_xml_entities = normalize_html_entities_for_xml(svg_text);
25 let without_foreign_objects = strip_foreign_objects(&with_xml_entities);
26 resolve_light_dark_functions(&without_foreign_objects)
27 }
28
29 pub fn rasterize_svg(svg_text: &str, scale: f32) -> Result<RasterizedSvg, SvgRasterizeError> {
30 let compatible_svg = Self::preprocess_for_rasterizer(svg_text);
31 let tree = usvg::Tree::from_str(&compatible_svg, &rasterizer_options())
32 .map_err(|e| SvgRasterizeError::ParseFailed(e.to_string()))?;
33 let raster = RasterTarget::new(tree.size(), scale);
34 let pixmap = raster.render(&tree)?;
35 Ok(RasterizedSvg {
36 width: raster.width,
37 height: raster.height,
38 display_width: raster.display_width,
39 display_height: raster.display_height,
40 rgba: pixmap.take(),
41 })
42 }
43}
44
45struct RasterTarget {
46 display_width: f32,
47 display_height: f32,
48 effective_scale: f32,
49 width: u32,
50 height: u32,
51}
52
53impl RasterTarget {
54 fn new(size: usvg::Size, scale: f32) -> Self {
55 let display_width = size.width();
56 let display_height = size.height();
57 let effective_scale = effective_scale(display_width, display_height, scale);
58 Self {
59 display_width,
60 display_height,
61 effective_scale,
62 width: ((display_width * effective_scale).ceil() as u32).max(1),
63 height: ((display_height * effective_scale).ceil() as u32).max(1),
64 }
65 }
66
67 fn render(&self, tree: &usvg::Tree) -> Result<Pixmap, SvgRasterizeError> {
68 let Some(mut pixmap) = Pixmap::new(self.width, self.height) else {
69 return Err(SvgRasterizeError::RasterizeFailed(
70 "failed to allocate SVG pixmap".to_string(),
71 ));
72 };
73 let transform =
74 tiny_skia::Transform::from_scale(self.effective_scale, self.effective_scale);
75 render(tree, transform, &mut pixmap.as_mut());
76 Ok(pixmap)
77 }
78}
79
80fn rasterizer_options() -> usvg::Options<'static> {
81 usvg::Options {
82 fontdb: font_db(),
84 ..usvg::Options::default()
85 }
86}
87
88fn normalize_html_entities_for_xml(svg_text: &str) -> String {
89 svg_text.replace(" ", " ")
90}
91
92fn strip_foreign_objects(svg_text: &str) -> String {
93 let mut output = String::with_capacity(svg_text.len());
94 let mut remaining = svg_text;
95 while let Some(start) = remaining.to_ascii_lowercase().find("<foreignobject") {
96 output.push_str(&remaining[..start]);
97 let after_open = &remaining[start..];
98 let lower_after_open = after_open.to_ascii_lowercase();
99 if let Some(self_close) = lower_after_open.find("/>") {
100 remaining = &after_open[self_close + "/>".len()..];
101 continue;
102 }
103 let Some(close) = lower_after_open.find("</foreignobject>") else {
104 output.push_str(after_open);
105 return output;
106 };
107 remaining = &after_open[close + "</foreignobject>".len()..];
108 }
109 output.push_str(remaining);
110 output
111}
112
113fn resolve_light_dark_functions(svg_text: &str) -> String {
114 let mut result = String::with_capacity(svg_text.len());
115 let mut remaining = svg_text;
116 while let Some(start) = find_light_dark_function(remaining) {
117 let content_start = start + LIGHT_DARK_FUNCTION.len();
118 result.push_str(&remaining[..start]);
119 let Some((content_end, light_color)) =
120 parse_light_dark_function(&remaining[content_start..])
121 else {
122 result.push_str(&remaining[start..content_start]);
123 remaining = &remaining[content_start..];
124 continue;
125 };
126 result.push_str(light_color.trim());
127 remaining = &remaining[content_start + content_end + 1..];
128 }
129 result.push_str(remaining);
130 result
131}
132
133fn find_light_dark_function(text: &str) -> Option<usize> {
134 text.to_ascii_lowercase().find(LIGHT_DARK_FUNCTION)
135}
136
137fn parse_light_dark_function(content: &str) -> Option<(usize, &str)> {
138 let mut depth = 0usize;
139 let mut comma = None;
140 for (index, character) in content.char_indices() {
141 match character {
142 '(' => depth += 1,
143 ')' if depth == 0 => return comma.map(|comma_index| (index, &content[..comma_index])),
144 ')' => depth -= 1,
145 ',' if depth == 0 && comma.is_none() => comma = Some(index),
146 _ => {}
147 }
148 }
149 None
150}
151
152fn font_db() -> std::sync::Arc<usvg::fontdb::Database> {
153 static FONT_DB: std::sync::OnceLock<std::sync::Arc<usvg::fontdb::Database>> =
154 std::sync::OnceLock::new();
155 std::sync::Arc::clone(FONT_DB.get_or_init(|| {
156 let mut db = usvg::fontdb::Database::new();
157 db.load_system_fonts();
158 std::sync::Arc::new(db)
159 }))
160}
161
162fn effective_scale(width: f32, height: f32, requested_scale: f32) -> f32 {
163 let positive_scale = requested_scale.max(f32::MIN_POSITIVE);
164 let width_scale = MAX_RASTERIZED_SVG_EDGE / width.max(1.0);
165 let height_scale = MAX_RASTERIZED_SVG_EDGE / height.max(1.0);
166 positive_scale.min(width_scale).min(height_scale)
167}
168
169#[derive(Debug, thiserror::Error)]
170pub enum SvgRasterizeError {
171 #[error("Failed to parse SVG: {0}")]
172 ParseFailed(String),
173 #[error("Failed to rasterize SVG: {0}")]
174 RasterizeFailed(String),
175}
176
177#[cfg(test)]
178#[path = "svg_rasterize_tests.rs"]
179mod tests;