Skip to main content

merman_render/svg/pipeline/builtin/
scoped_css.rs

1use crate::Result;
2use std::borrow::Cow;
3
4use super::css_override::{CssOverridePolicy, strip_css_important};
5use super::util::{escape_xml_attr, find_matching_brace, find_tag_end};
6use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
7
8#[derive(Debug, Clone)]
9pub struct ScopedCssPostprocessor {
10    css: String,
11    override_policy: CssOverridePolicy,
12}
13
14impl ScopedCssPostprocessor {
15    pub fn new(css: impl Into<String>) -> Self {
16        Self {
17            css: css.into(),
18            override_policy: CssOverridePolicy::Preserve,
19        }
20    }
21
22    pub fn with_override_policy(mut self, policy: CssOverridePolicy) -> Self {
23        self.override_policy = policy;
24        self
25    }
26
27    pub fn css(&self) -> &str {
28        &self.css
29    }
30
31    pub fn override_policy(&self) -> CssOverridePolicy {
32        self.override_policy
33    }
34}
35
36impl SvgPostprocessor for ScopedCssPostprocessor {
37    fn name(&self) -> &'static str {
38        "scoped-css"
39    }
40
41    fn process<'a>(
42        &self,
43        svg: Cow<'a, str>,
44        ctx: &SvgPostprocessContext<'_>,
45    ) -> Result<Cow<'a, str>> {
46        if self.css.trim().is_empty() {
47            return Ok(svg);
48        }
49
50        let mut base = match self.override_policy {
51            CssOverridePolicy::Preserve => svg.into_owned(),
52            CssOverridePolicy::StripExistingImportant => strip_css_important(svg.as_ref()),
53        };
54        let css = decode_mermaid_css_hash_placeholders(&self.css);
55        let scoped_css = scope_css(css.as_ref(), ctx.svg_id());
56        inject_style(&mut base, &scoped_css);
57        Ok(Cow::Owned(base))
58    }
59}
60
61fn inject_style(svg: &mut String, css: &str) {
62    let css = css.replace("</style", "<\\/style");
63    let style = format!(
64        r#"<style data-merman-postprocess="scoped-css">{}</style>"#,
65        css
66    );
67
68    if let Some(start) = svg.find("<svg")
69        && let Some(end) = find_tag_end(svg, start)
70    {
71        if let Some(style_close_start) = svg.rfind("</style")
72            && let Some(style_close_end) = find_tag_end(svg, style_close_start)
73        {
74            svg.insert_str(style_close_end + 1, &style);
75            return;
76        }
77        svg.insert_str(end + 1, &style);
78        return;
79    }
80
81    svg.push_str(&style);
82}
83
84fn scope_css(css: &str, svg_id: Option<&str>) -> String {
85    let Some(svg_id) = svg_id.filter(|id| !id.trim().is_empty()) else {
86        return css.to_string();
87    };
88    let scope = format!("#{}", css_escape_id(svg_id));
89    scope_css_block(css, &scope)
90}
91
92fn decode_mermaid_css_hash_placeholders(css: &str) -> Cow<'_, str> {
93    if !css.contains('fl') && !css.contains('¶') {
94        return Cow::Borrowed(css);
95    }
96
97    Cow::Owned(
98        css.replace("fl°°", "#")
99            .replace("fl°", "#")
100            .replace("¶ß", ";"),
101    )
102}
103
104fn scope_css_block(css: &str, scope: &str) -> String {
105    let mut out = String::with_capacity(css.len() + scope.len() * 4);
106    let mut cursor = 0;
107
108    while let Some(rel_open) = css[cursor..].find('{') {
109        let open = cursor + rel_open;
110        let selector_start = css[cursor..open]
111            .rfind(';')
112            .map(|rel| cursor + rel + 1)
113            .unwrap_or(cursor);
114        out.push_str(&scope_css_statement_prefix(&css[cursor..selector_start]));
115        let selector = &css[selector_start..open];
116        let Some(close) = find_matching_brace(css, open) else {
117            out.push_str(&css[cursor..]);
118            return out;
119        };
120
121        if selector.trim_start().starts_with('@') {
122            push_scoped_at_rule(&mut out, selector, &css[open + 1..close], scope);
123        } else {
124            out.push_str(&scope_selector(selector, scope));
125            out.push(' ');
126            out.push_str(&css[open..=close]);
127        }
128        cursor = close + 1;
129    }
130
131    out.push_str(&css[cursor..]);
132    out
133}
134
135fn scope_css_statement_prefix(prefix: &str) -> String {
136    let trimmed = prefix.trim_start();
137    if trimmed.starts_with("@import")
138        || trimmed.starts_with("@namespace")
139        || trimmed.starts_with("@charset")
140    {
141        String::new()
142    } else {
143        prefix.to_string()
144    }
145}
146
147fn push_scoped_at_rule(out: &mut String, selector: &str, body: &str, scope: &str) {
148    let name = selector
149        .trim_start()
150        .split(|ch: char| ch.is_whitespace() || ch == '{')
151        .next()
152        .unwrap_or("")
153        .to_ascii_lowercase();
154
155    if is_css_keyframes_rule(&name) {
156        out.push_str(selector);
157        out.push('{');
158        out.push_str(body);
159        out.push('}');
160    } else if is_css_grouping_rule(&name) {
161        out.push_str(selector);
162        out.push('{');
163        out.push_str(&scope_css_block(body, scope));
164        out.push('}');
165    }
166}
167
168fn is_css_keyframes_rule(name: &str) -> bool {
169    name == "@keyframes" || name == "@-webkit-keyframes"
170}
171
172fn is_css_grouping_rule(name: &str) -> bool {
173    matches!(
174        name,
175        "@media" | "@supports" | "@layer" | "@scope" | "@container" | "@starting-style"
176    )
177}
178
179fn scope_selector(selector: &str, scope: &str) -> String {
180    selector
181        .split(',')
182        .map(|part| {
183            let trimmed = part.trim();
184            if trimmed.is_empty() {
185                String::new()
186            } else if trimmed == ":root" || trimmed == "svg" {
187                scope.to_string()
188            } else {
189                let expanded = trimmed.replace('&', scope);
190                if expanded.starts_with(scope) {
191                    expanded
192                } else {
193                    format!("{scope} {expanded}")
194                }
195            }
196        })
197        .collect::<Vec<_>>()
198        .join(", ")
199}
200
201fn css_escape_id(id: &str) -> String {
202    let mut out = String::with_capacity(id.len());
203    for ch in id.chars() {
204        let ok = ch.is_ascii_alphanumeric() || ch == '-' || ch == '_';
205        if ok {
206            out.push(ch);
207        } else {
208            out.push('\\');
209            out.push(ch);
210        }
211    }
212    out
213}
214
215#[allow(dead_code)]
216fn scoped_attr_selector(id: &str) -> String {
217    format!(r#"svg[id="{}"]"#, escape_xml_attr(id))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::svg::pipeline::SvgPipeline;
224
225    #[test]
226    fn scoped_css_injects_after_root_svg_tag_when_no_style_exists() {
227        let svg = r#"<svg id="diagram"><rect class="node"/></svg>"#;
228        let out = SvgPipeline::parity()
229            .with_postprocessor(ScopedCssPostprocessor::new(
230                ".node rect, text.label { fill: red; }",
231            ))
232            .process_to_string(svg)
233            .unwrap();
234
235        assert!(out.starts_with(r#"<svg id="diagram"><style"#));
236        assert!(out.contains("#diagram .node rect, #diagram text.label { fill: red; }"));
237    }
238
239    #[test]
240    fn scoped_css_injects_after_existing_style_for_cascade_order() {
241        let svg =
242            r#"<svg id="diagram"><style>#diagram .node rect { fill: red; }</style><g/></svg>"#;
243        let out = SvgPipeline::parity()
244            .with_postprocessor(ScopedCssPostprocessor::new(".node rect { fill: green; }"))
245            .process_to_string(svg)
246            .unwrap();
247
248        let existing = out.find("fill: red").unwrap();
249        let injected = out.find("fill: green").unwrap();
250        assert!(
251            existing < injected,
252            "injected CSS should follow Mermaid CSS for cascade order: {out}"
253        );
254    }
255
256    #[test]
257    fn scoped_css_can_strip_existing_important_before_injection() {
258        let svg = r#"<svg id="diagram"><style>.node{fill:red !important;}</style></svg>"#;
259        let out = SvgPipeline::parity()
260            .with_postprocessor(
261                ScopedCssPostprocessor::new(".node { fill: green; }")
262                    .with_override_policy(CssOverridePolicy::StripExistingImportant),
263            )
264            .process_to_string(svg)
265            .unwrap();
266
267        assert!(!out.contains("!important"));
268        assert!(out.contains("#diagram .node { fill: green; }"));
269    }
270
271    #[test]
272    fn scoped_css_matches_mermaid_ampersand_selector_namespace() {
273        let svg = r#"<svg id="diagram"><g/></svg>"#;
274        let out = SvgPipeline::parity()
275            .with_postprocessor(ScopedCssPostprocessor::new(
276                ":not(&){background:green !important}",
277            ))
278            .process_to_string(svg)
279            .unwrap();
280
281        assert!(out.contains("#diagram :not(#diagram) {background:green !important}"));
282    }
283
284    #[test]
285    fn scoped_css_scopes_nested_grouping_at_rules_and_drops_unsupported_rules() {
286        let svg = r#"<svg id="diagram"><g/></svg>"#;
287        let out = SvgPipeline::parity()
288            .with_postprocessor(ScopedCssPostprocessor::new(
289                "@import url('https://example.test/styles.css'); @media (max-width: 600px) { * { fill: red; } } @supports selector(h2 > p) { h2 > p { color: red; } }",
290            ))
291            .process_to_string(svg)
292            .unwrap();
293
294        assert!(!out.contains("@import"));
295        assert!(out.contains("@media (max-width: 600px) {"));
296        assert!(out.contains("#diagram * { fill: red; }"));
297        assert!(out.contains("@supports selector(h2 > p) {"));
298        assert!(out.contains("#diagram h2 > p { color: red; }"));
299    }
300
301    #[test]
302    fn scoped_css_keeps_keyframes_unscoped_like_mermaid() {
303        let svg = r#"<svg id="diagram"><g/></svg>"#;
304        let out = SvgPipeline::parity()
305            .with_postprocessor(ScopedCssPostprocessor::new(
306                "@keyframes dash { to { stroke-dashoffset: 1000; } } .edge { animation: dash 1s; }",
307            ))
308            .process_to_string(svg)
309            .unwrap();
310
311        assert!(out.contains("@keyframes dash { to { stroke-dashoffset: 1000; } }"));
312        assert!(out.contains("#diagram .edge { animation: dash 1s; }"));
313    }
314
315    #[test]
316    fn scoped_css_decodes_mermaid_hash_placeholders_as_css_hashes() {
317        let svg = r#"<svg id="diagram"><g/></svg>"#;
318        let out = SvgPipeline::parity()
319            .with_postprocessor(ScopedCssPostprocessor::new(".node { fill: fl°°123456¶ß }"))
320            .process_to_string(svg)
321            .unwrap();
322
323        assert!(out.contains("#diagram .node { fill: #123456; }"));
324    }
325}