Skip to main content

katana_render_runtime/markdown/
svg_rasterize.rs

1/* WHY: SVG rasterization utility.
2Uses `resvg` + `usvg` to convert SVG text to an RGBA pixel buffer.
3Returns the result as raw bytes compatible with egui's `ColorImage`. */
4
5use 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        /* WHY: Text inside SVG becomes invisible if system fonts are not provided. */
83        fontdb: font_db(),
84        ..usvg::Options::default()
85    }
86}
87
88fn normalize_html_entities_for_xml(svg_text: &str) -> String {
89    svg_text.replace("&nbsp;", "&#160;")
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;