merman_render/svg/pipeline/builtin/
scoped_css.rs1use 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}