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 #[test]
238 fn css_override_strips_important_only_when_requested() {
239 let svg = r#"<svg><style>.node{fill:red !important;}</style></svg>"#;
240
241 let preserve = SvgPipeline::parity()
242 .with_postprocessor(CssOverridePostprocessor::new(CssOverridePolicy::Preserve))
243 .process_to_string(svg)
244 .unwrap();
245 let strip = SvgPipeline::parity()
246 .with_postprocessor(CssOverridePostprocessor::strip_existing_important())
247 .process_to_string(svg)
248 .unwrap();
249
250 assert!(preserve.contains("!important"));
251 assert!(!strip.contains("!important"));
252 }
253
254 #[test]
255 fn css_override_important_scanner_preserves_regex_boundaries() {
256 assert_eq!(
257 strip_css_important_from_css(
258 ".a{fill:red\t!important;stroke:blue !IMPORTANT;color:green !importantfoo;}"
259 ),
260 ".a{fill:red;stroke:blue;color:green !importantfoo;}"
261 );
262 assert_eq!(
263 strip_css_important_from_css(".a{fill:red!important-border;color:blue !importanté;}"),
264 ".a{fill:red-border;color:blue !importanté;}"
265 );
266 }
267
268 #[test]
269 fn css_override_strips_important_only_from_css_contexts() {
270 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>"#;
271 let out = strip_css_important(svg);
272
273 assert!(out.contains(".node{fill:red;}"), "got: {out}");
274 assert!(
275 out.contains("style=\"stroke: blue; fill: green\""),
276 "got: {out}"
277 );
278 assert!(out.contains("<text>keep !important</text>"), "got: {out}");
279 assert!(out.contains("data-note=\"keep !important\""), "got: {out}");
280 }
281
282 #[test]
283 fn css_override_handles_single_quoted_style_attributes() {
284 let svg = r#"<svg><path style='stroke: blue !important; fill: green'/></svg>"#;
285 let out = strip_css_important(svg);
286
287 assert!(
288 out.contains("style='stroke: blue; fill: green'"),
289 "got: {out}"
290 );
291 }
292}