Skip to main content

merman_render/svg/pipeline/builtin/
css_sanitize.rs

1use crate::Result;
2use regex::{Captures, Regex};
3use std::borrow::Cow;
4use std::sync::OnceLock;
5
6use super::util::{find_matching_brace, find_tag_end};
7use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
8
9#[derive(Debug, Clone, Copy, Default)]
10pub struct SanitizeCssPostprocessor;
11
12impl SvgPostprocessor for SanitizeCssPostprocessor {
13    fn name(&self) -> &'static str {
14        "sanitize-css"
15    }
16
17    fn process<'a>(
18        &self,
19        svg: Cow<'a, str>,
20        _ctx: &SvgPostprocessContext<'_>,
21    ) -> Result<Cow<'a, str>> {
22        if !svg.contains("<style") && !svg.contains("style=\"") {
23            return Ok(svg);
24        }
25        Ok(Cow::Owned(sanitize_style_elements(&svg)))
26    }
27}
28
29pub(crate) fn sanitize_style_elements(svg: &str) -> String {
30    let mut out = String::with_capacity(svg.len());
31    let mut cursor = 0;
32
33    while let Some(rel_start) = svg[cursor..].find("<style") {
34        let start = cursor + rel_start;
35        out.push_str(&svg[cursor..start]);
36
37        let Some(open_end) = find_tag_end(svg, start) else {
38            out.push_str(&svg[start..]);
39            return out;
40        };
41
42        let content_start = open_end + 1;
43        let Some(rel_close_start) = svg[content_start..].find("</style") else {
44            out.push_str(&svg[start..]);
45            return out;
46        };
47        let close_start = content_start + rel_close_start;
48        let Some(close_end) = find_tag_end(svg, close_start) else {
49            out.push_str(&svg[start..]);
50            return out;
51        };
52
53        out.push_str(&svg[start..=open_end]);
54        out.push_str(&sanitize_css(&svg[content_start..close_start]));
55        out.push_str(&svg[close_start..=close_end]);
56        cursor = close_end + 1;
57    }
58
59    out.push_str(&svg[cursor..]);
60    out
61}
62
63pub(crate) fn sanitize_css(css: &str) -> String {
64    let css = strip_unsupported_css_rules(css);
65    let css = strip_animation_declarations(&css);
66    strip_css_deg_units(&css)
67}
68
69fn strip_unsupported_css_rules(css: &str) -> String {
70    let mut out = String::with_capacity(css.len());
71    let mut cursor = 0;
72
73    while let Some(rel_open) = css[cursor..].find('{') {
74        let open = cursor + rel_open;
75        let selector = &css[cursor..open];
76        let Some(close) = find_matching_brace(css, open) else {
77            out.push_str(&css[cursor..]);
78            return out;
79        };
80
81        let selector_lower = selector.to_ascii_lowercase();
82        let unsupported = selector_lower.contains("@keyframes")
83            || selector_lower.contains("@-webkit-keyframes")
84            || selector_lower.contains(":root");
85
86        if !unsupported {
87            out.push_str(&css[cursor..=close]);
88        }
89        cursor = close + 1;
90    }
91
92    out.push_str(&css[cursor..]);
93    out
94}
95
96fn strip_animation_declarations(css: &str) -> String {
97    static RE: OnceLock<Regex> = OnceLock::new();
98    let re = RE.get_or_init(|| {
99        Regex::new(r"(?i)(^|[;{])\s*animation(?:-[a-z-]+)?\s*:[^;}]*;?")
100            .expect("valid animation declaration regex")
101    });
102
103    re.replace_all(css, |caps: &Captures<'_>| caps[1].to_string())
104        .into_owned()
105}
106
107pub(crate) fn strip_css_deg_units(css: &str) -> String {
108    static RE: OnceLock<Regex> = OnceLock::new();
109    let re = RE
110        .get_or_init(|| Regex::new(r"(?i)(-?\d+(?:\.\d+)?)deg\b").expect("valid CSS degree regex"));
111
112    re.replace_all(css, "$1").into_owned()
113}