Skip to main content

merman_render/svg/pipeline/builtin/
css_sanitize.rs

1use crate::Result;
2use std::borrow::Cow;
3
4use super::util::{find_matching_brace, find_tag_end};
5use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
6
7#[derive(Debug, Clone, Copy, Default)]
8pub struct SanitizeCssPostprocessor;
9
10impl SvgPostprocessor for SanitizeCssPostprocessor {
11    fn name(&self) -> &'static str {
12        "sanitize-css"
13    }
14
15    fn process<'a>(
16        &self,
17        svg: Cow<'a, str>,
18        _ctx: &SvgPostprocessContext<'_>,
19    ) -> Result<Cow<'a, str>> {
20        if !svg.contains("<style") && !svg.contains("style=\"") {
21            return Ok(svg);
22        }
23        Ok(Cow::Owned(sanitize_style_elements(&svg)))
24    }
25}
26
27pub(crate) fn sanitize_style_elements(svg: &str) -> String {
28    let mut out = String::with_capacity(svg.len());
29    let mut cursor = 0;
30
31    while let Some(rel_start) = svg[cursor..].find("<style") {
32        let start = cursor + rel_start;
33        out.push_str(&svg[cursor..start]);
34
35        let Some(open_end) = find_tag_end(svg, start) else {
36            out.push_str(&svg[start..]);
37            return out;
38        };
39
40        let content_start = open_end + 1;
41        let Some(rel_close_start) = svg[content_start..].find("</style") else {
42            out.push_str(&svg[start..]);
43            return out;
44        };
45        let close_start = content_start + rel_close_start;
46        let Some(close_end) = find_tag_end(svg, close_start) else {
47            out.push_str(&svg[start..]);
48            return out;
49        };
50
51        out.push_str(&svg[start..=open_end]);
52        out.push_str(&sanitize_css(&svg[content_start..close_start]));
53        out.push_str(&svg[close_start..=close_end]);
54        cursor = close_end + 1;
55    }
56
57    out.push_str(&svg[cursor..]);
58    out
59}
60
61pub(crate) fn sanitize_css(css: &str) -> String {
62    let css = strip_unsupported_css_rules(css);
63    let css = strip_animation_declarations(&css);
64    strip_css_deg_units(&css)
65}
66
67fn strip_unsupported_css_rules(css: &str) -> String {
68    let mut out = String::with_capacity(css.len());
69    let mut cursor = 0;
70
71    while let Some(rel_open) = css[cursor..].find('{') {
72        let open = cursor + rel_open;
73        let selector = &css[cursor..open];
74        let Some(close) = find_matching_brace(css, open) else {
75            out.push_str(&css[cursor..]);
76            return out;
77        };
78
79        let selector_lower = selector.to_ascii_lowercase();
80        let unsupported = selector_lower.contains("@keyframes")
81            || selector_lower.contains("@-webkit-keyframes")
82            || selector_lower.contains(":root");
83
84        if !unsupported {
85            out.push_str(&css[cursor..=close]);
86        }
87        cursor = close + 1;
88    }
89
90    out.push_str(&css[cursor..]);
91    out
92}
93
94fn strip_animation_declarations(css: &str) -> String {
95    let mut out = String::with_capacity(css.len());
96    let mut copied_until = 0usize;
97    let mut cursor = 0usize;
98
99    while cursor < css.len() {
100        if cursor == 0 {
101            if let Some(end) = animation_declaration_end_after_delimiter(css, 0) {
102                out.push_str(&css[copied_until..cursor]);
103                copied_until = end;
104                cursor = end;
105                continue;
106            }
107        }
108
109        let Some(ch) = css[cursor..].chars().next() else {
110            break;
111        };
112        if matches!(ch, ';' | '{') {
113            let after_delimiter = cursor + ch.len_utf8();
114            if let Some(end) = animation_declaration_end_after_delimiter(css, after_delimiter) {
115                out.push_str(&css[copied_until..cursor]);
116                out.push(ch);
117                copied_until = end;
118                cursor = end;
119                continue;
120            }
121        }
122
123        cursor += ch.len_utf8();
124    }
125
126    out.push_str(&css[copied_until..]);
127    out
128}
129
130fn animation_declaration_end_after_delimiter(css: &str, start: usize) -> Option<usize> {
131    let mut cursor = skip_css_regex_whitespace(css, start);
132    let name_end = cursor + "animation".len();
133    if !css.get(cursor..name_end)?.eq_ignore_ascii_case("animation") {
134        return None;
135    }
136    cursor = name_end;
137
138    if css.get(cursor..)?.starts_with('-') {
139        let suffix_start = cursor + 1;
140        let suffix_end = consume_ascii_alpha_hyphen(css, suffix_start);
141        if suffix_end == suffix_start {
142            return None;
143        }
144        cursor = suffix_end;
145    }
146
147    cursor = skip_css_regex_whitespace(css, cursor);
148    if !css.get(cursor..)?.starts_with(':') {
149        return None;
150    }
151    cursor += 1;
152
153    while let Some(ch) = css.get(cursor..)?.chars().next() {
154        if ch == ';' {
155            return Some(cursor + 1);
156        }
157        if ch == '}' {
158            return Some(cursor);
159        }
160        cursor += ch.len_utf8();
161    }
162
163    Some(cursor)
164}
165
166pub(crate) fn strip_css_deg_units(css: &str) -> String {
167    let mut out = String::with_capacity(css.len());
168    let mut copied_until = 0usize;
169    let mut cursor = 0usize;
170
171    while cursor < css.len() {
172        if let Some((number_end, unit_end)) = css_deg_unit_match_at(css, cursor) {
173            out.push_str(&css[copied_until..number_end]);
174            copied_until = unit_end;
175            cursor = unit_end;
176            continue;
177        }
178
179        let Some(ch) = css[cursor..].chars().next() else {
180            break;
181        };
182        cursor += ch.len_utf8();
183    }
184
185    out.push_str(&css[copied_until..]);
186    out
187}
188
189fn css_deg_unit_match_at(css: &str, start: usize) -> Option<(usize, usize)> {
190    let mut cursor = start;
191    if css.get(cursor..)?.starts_with('-') {
192        cursor += 1;
193    }
194
195    let digit_start = cursor;
196    cursor = consume_ascii_digits(css, cursor);
197    if cursor == digit_start {
198        return None;
199    }
200
201    if css.get(cursor..)?.starts_with('.') {
202        let fraction_start = cursor + 1;
203        let fraction_end = consume_ascii_digits(css, fraction_start);
204        if fraction_end == fraction_start {
205            return None;
206        }
207        cursor = fraction_end;
208    }
209
210    let unit_end = cursor + "deg".len();
211    if !css.get(cursor..unit_end)?.eq_ignore_ascii_case("deg") {
212        return None;
213    }
214    if let Some(next) = css.get(unit_end..).and_then(|tail| tail.chars().next()) {
215        if is_css_regex_word_char(next) {
216            return None;
217        }
218    }
219
220    Some((cursor, unit_end))
221}
222
223fn skip_css_regex_whitespace(css: &str, mut cursor: usize) -> usize {
224    while let Some(ch) = css.get(cursor..).and_then(|tail| tail.chars().next()) {
225        if !ch.is_whitespace() {
226            break;
227        }
228        cursor += ch.len_utf8();
229    }
230    cursor
231}
232
233fn consume_ascii_alpha_hyphen(css: &str, mut cursor: usize) -> usize {
234    while let Some(b) = css.as_bytes().get(cursor) {
235        if !(b.is_ascii_alphabetic() || *b == b'-') {
236            break;
237        }
238        cursor += 1;
239    }
240    cursor
241}
242
243fn consume_ascii_digits(css: &str, mut cursor: usize) -> usize {
244    while let Some(b) = css.as_bytes().get(cursor) {
245        if !b.is_ascii_digit() {
246            break;
247        }
248        cursor += 1;
249    }
250    cursor
251}
252
253fn is_css_regex_word_char(ch: char) -> bool {
254    ch == '_' || ch.is_alphanumeric()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn css_sanitize_strips_animation_declarations_without_regex() {
263        assert_eq!(
264            strip_animation_declarations(".a{ animation: spin 1s; fill:red; }"),
265            ".a{ fill:red; }"
266        );
267        assert_eq!(
268            strip_animation_declarations("; animation-duration: 1s; stroke:blue"),
269            "; stroke:blue"
270        );
271        assert_eq!(
272            strip_animation_declarations("x animation:spin;"),
273            "x animation:spin;"
274        );
275        assert_eq!(
276            strip_animation_declarations("animation-:keep;animation--:drop;fill:red"),
277            "animation-:keep;fill:red"
278        );
279    }
280
281    #[test]
282    fn css_sanitize_strips_deg_units_without_regex() {
283        assert_eq!(
284            strip_css_deg_units(
285                "rotate(45deg) rotate(-10.5DEG) 90degree 1.deg .5deg 90deg-foo 90degé"
286            ),
287            "rotate(45) rotate(-10.5) 90degree 1.deg .5 90-foo 90degé"
288        );
289    }
290}