merman_render/svg/pipeline/builtin/
css_sanitize.rs1use 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}