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