merman_render/svg/pipeline/builtin/
css_override.rs1use crate::Result;
2use std::borrow::Cow;
3
4use super::util::{find_tag_end, next_svg_quoted_attr};
5use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum CssOverridePolicy {
9 #[default]
10 Preserve,
11 StripExistingImportant,
12}
13
14#[derive(Debug, Clone, Copy, Default)]
15pub struct CssOverridePostprocessor {
16 policy: CssOverridePolicy,
17}
18
19impl CssOverridePostprocessor {
20 pub fn new(policy: CssOverridePolicy) -> Self {
21 Self { policy }
22 }
23
24 pub fn strip_existing_important() -> Self {
25 Self::new(CssOverridePolicy::StripExistingImportant)
26 }
27
28 pub fn policy(&self) -> CssOverridePolicy {
29 self.policy
30 }
31}
32
33impl SvgPostprocessor for CssOverridePostprocessor {
34 fn name(&self) -> &'static str {
35 "css-override"
36 }
37
38 fn process<'a>(
39 &self,
40 svg: Cow<'a, str>,
41 _ctx: &SvgPostprocessContext<'_>,
42 ) -> Result<Cow<'a, str>> {
43 match self.policy {
44 CssOverridePolicy::Preserve => Ok(svg),
45 CssOverridePolicy::StripExistingImportant => {
46 Ok(Cow::Owned(strip_css_important(svg.as_ref())))
47 }
48 }
49 }
50}
51
52pub(crate) fn strip_css_important(svg: &str) -> String {
53 if !svg.contains('!') {
54 return svg.to_string();
55 }
56
57 let svg = strip_css_important_in_style_elements(svg);
58 strip_css_important_in_style_attrs(&svg)
59}
60
61fn strip_css_important_in_style_elements(svg: &str) -> String {
62 let mut out = String::with_capacity(svg.len());
63 let mut cursor = 0;
64 let mut saw_style = false;
65
66 while let Some(rel_start) = svg[cursor..].find("<style") {
67 saw_style = true;
68 let start = cursor + rel_start;
69 out.push_str(&svg[cursor..start]);
70
71 let Some(open_end) = find_tag_end(svg, start) else {
72 out.push_str(&svg[start..]);
73 return out;
74 };
75
76 let content_start = open_end + 1;
77 let Some(rel_close_start) = svg[content_start..].find("</style") else {
78 out.push_str(&svg[start..]);
79 return out;
80 };
81 let close_start = content_start + rel_close_start;
82 let Some(close_end) = find_tag_end(svg, close_start) else {
83 out.push_str(&svg[start..]);
84 return out;
85 };
86
87 out.push_str(&svg[start..=open_end]);
88 out.push_str(&strip_css_important_from_css(
89 &svg[content_start..close_start],
90 ));
91 out.push_str(&svg[close_start..=close_end]);
92 cursor = close_end + 1;
93 }
94
95 if !saw_style {
96 return svg.to_string();
97 }
98
99 out.push_str(&svg[cursor..]);
100 out
101}
102
103fn strip_css_important_in_style_attrs(svg: &str) -> String {
104 let mut out = String::with_capacity(svg.len());
105 let mut cursor = 0;
106 let mut saw_tag = false;
107
108 while let Some(rel_start) = svg[cursor..].find('<') {
109 saw_tag = true;
110 let start = cursor + rel_start;
111 out.push_str(&svg[cursor..start]);
112
113 let Some(end) = find_tag_end(svg, start) else {
114 out.push_str(&svg[start..]);
115 return out;
116 };
117
118 out.push_str(&strip_css_important_in_tag_style_attrs(&svg[start..=end]));
119 cursor = end + 1;
120 }
121
122 if !saw_tag {
123 return svg.to_string();
124 }
125
126 out.push_str(&svg[cursor..]);
127 out
128}
129
130fn strip_css_important_in_tag_style_attrs(tag: &str) -> Cow<'_, str> {
131 if tag.starts_with("</")
132 || tag.starts_with("<!--")
133 || tag.starts_with("<!")
134 || tag.starts_with("<?")
135 {
136 return Cow::Borrowed(tag);
137 }
138
139 let mut out = String::new();
140 let mut copied_until = 0usize;
141 let mut cursor = 0usize;
142 let mut changed = false;
143
144 while let Some(attr) = next_svg_quoted_attr(tag, cursor) {
145 let name = &tag[attr.name_start..attr.name_end];
146 if !name.eq_ignore_ascii_case("style") {
147 cursor = attr.full_end;
148 continue;
149 }
150
151 let value = &tag[attr.value_start..attr.value_end];
152 let stripped = strip_css_important_from_css(value);
153 if stripped == value {
154 cursor = attr.full_end;
155 continue;
156 }
157
158 if !changed {
159 out = String::with_capacity(tag.len());
160 changed = true;
161 }
162 out.push_str(&tag[copied_until..attr.value_start]);
163 out.push_str(&stripped);
164 copied_until = attr.value_end;
165 cursor = attr.full_end;
166 }
167
168 if changed {
169 out.push_str(&tag[copied_until..]);
170 Cow::Owned(out)
171 } else {
172 Cow::Borrowed(tag)
173 }
174}
175
176fn strip_css_important_from_css(css: &str) -> String {
177 let mut out = String::with_capacity(css.len());
178 let mut copied_until = 0usize;
179 let mut search_from = 0usize;
180 let mut stripped = false;
181
182 while let Some(rel) = css[search_from..].find('!') {
183 let bang = search_from + rel;
184 if let Some((start, end)) = css_important_match_bounds_at_bang(css, bang) {
185 out.push_str(&css[copied_until..start]);
186 copied_until = end;
187 search_from = end;
188 stripped = true;
189 continue;
190 }
191
192 search_from = bang + 1;
193 }
194
195 if !stripped {
196 return css.to_string();
197 }
198
199 out.push_str(&css[copied_until..]);
200 out
201}
202
203fn css_important_match_bounds_at_bang(svg: &str, bang: usize) -> Option<(usize, usize)> {
204 let marker_end = bang + "!important".len();
205 if !svg
206 .get(bang..marker_end)?
207 .eq_ignore_ascii_case("!important")
208 {
209 return None;
210 }
211
212 if let Some(next) = svg.get(marker_end..).and_then(|tail| tail.chars().next())
213 && is_css_regex_word_char(next)
214 {
215 return None;
216 }
217
218 let start = svg[..bang]
219 .char_indices()
220 .rev()
221 .find(|(_, ch)| !ch.is_whitespace())
222 .map(|(idx, ch)| idx + ch.len_utf8())
223 .unwrap_or(0);
224
225 Some((start, marker_end))
226}
227
228fn is_css_regex_word_char(ch: char) -> bool {
229 ch == '_' || ch.is_alphanumeric()
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::svg::pipeline::SvgPipeline;
236
237 fn render_session() -> crate::environment::RenderSession {
238 crate::environment::RenderEnvironment::deterministic()
239 .begin_session()
240 .unwrap()
241 }
242
243 #[test]
244 fn css_override_strips_important_only_when_requested() {
245 let svg = r#"<svg><style>.node{fill:red !important;}</style></svg>"#;
246 let session = render_session();
247
248 let preserve = SvgPipeline::parity()
249 .with_postprocessor(CssOverridePostprocessor::new(CssOverridePolicy::Preserve))
250 .process_to_string(svg, &session)
251 .unwrap();
252 let strip = SvgPipeline::parity()
253 .with_postprocessor(CssOverridePostprocessor::strip_existing_important())
254 .process_to_string(svg, &session)
255 .unwrap();
256
257 assert!(preserve.contains("!important"));
258 assert!(!strip.contains("!important"));
259 }
260
261 #[test]
262 fn css_override_important_scanner_preserves_regex_boundaries() {
263 assert_eq!(
264 strip_css_important_from_css(
265 ".a{fill:red\t!important;stroke:blue !IMPORTANT;color:green !importantfoo;}"
266 ),
267 ".a{fill:red;stroke:blue;color:green !importantfoo;}"
268 );
269 assert_eq!(
270 strip_css_important_from_css(".a{fill:red!important-border;color:blue !importanté;}"),
271 ".a{fill:red-border;color:blue !importanté;}"
272 );
273 }
274
275 #[test]
276 fn css_override_strips_important_only_from_css_contexts() {
277 let svg = r#"<svg><style>.node{fill:red !important;}</style><text>keep !important</text><path data-note="keep !important" style="stroke: blue !important; fill: green"/></svg>"#;
278 let out = strip_css_important(svg);
279
280 assert!(out.contains(".node{fill:red;}"), "got: {out}");
281 assert!(
282 out.contains("style=\"stroke: blue; fill: green\""),
283 "got: {out}"
284 );
285 assert!(out.contains("<text>keep !important</text>"), "got: {out}");
286 assert!(out.contains("data-note=\"keep !important\""), "got: {out}");
287 }
288
289 #[test]
290 fn css_override_handles_single_quoted_style_attributes() {
291 let svg = r#"<svg><path style='stroke: blue !important; fill: green'/></svg>"#;
292 let out = strip_css_important(svg);
293
294 assert!(
295 out.contains("style='stroke: blue; fill: green'"),
296 "got: {out}"
297 );
298 }
299}