merman_render/svg/pipeline/builtin/
css_sanitize.rs1use 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 && let Some(end) = animation_declaration_end_after_delimiter(css, 0)
102 {
103 out.push_str(&css[copied_until..cursor]);
104 copied_until = end;
105 cursor = end;
106 continue;
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 && is_css_regex_word_char(next)
216 {
217 return None;
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}