Skip to main content

merman_render/svg/pipeline/builtin/
css_override.rs

1use 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}