Skip to main content

merman_render/svg/pipeline/builtin/
scoped_css.rs

1use crate::{Error, Result};
2use std::borrow::Cow;
3
4use super::css_override::{CssOverridePolicy, strip_css_important};
5use super::util::{
6    checkpoint_loop, find_tag_end_with_checkpoints, find_with_checkpoints, rfind_with_checkpoints,
7    trim_with_checkpoints,
8};
9use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessExecution, SvgPostprocessor};
10
11mod rewrite;
12
13const STYLE_OPEN: &str = r#"<style data-merman-postprocess="scoped-css">"#;
14const STYLE_CLOSE: &str = "</style>";
15
16#[derive(Debug, Clone)]
17pub struct ScopedCssPostprocessor {
18    css: String,
19    override_policy: CssOverridePolicy,
20    merge_into_existing_style: bool,
21}
22
23impl ScopedCssPostprocessor {
24    pub fn new(css: impl Into<String>) -> Self {
25        Self {
26            css: css.into(),
27            override_policy: CssOverridePolicy::Preserve,
28            merge_into_existing_style: false,
29        }
30    }
31
32    pub fn with_override_policy(mut self, policy: CssOverridePolicy) -> Self {
33        self.override_policy = policy;
34        self
35    }
36
37    pub fn css(&self) -> &str {
38        &self.css
39    }
40
41    pub fn override_policy(&self) -> CssOverridePolicy {
42        self.override_policy
43    }
44
45    pub fn with_existing_style_merge(mut self) -> Self {
46        self.merge_into_existing_style = true;
47        self
48    }
49}
50
51impl SvgPostprocessor for ScopedCssPostprocessor {
52    fn name(&self) -> &'static str {
53        "scoped-css"
54    }
55
56    fn scoped_css(&self) -> Option<&str> {
57        Some(&self.css)
58    }
59
60    fn process<'a>(
61        &self,
62        svg: Cow<'a, str>,
63        ctx: &SvgPostprocessContext<'_>,
64    ) -> Result<Cow<'a, str>> {
65        let execution = ctx.execution();
66        execution.checkpoint()?;
67        let mut checkpoint = || execution.checkpoint();
68        if trim_with_checkpoints(&self.css, &mut checkpoint)?.is_empty() {
69            return Ok(svg);
70        }
71
72        let base = match self.override_policy {
73            CssOverridePolicy::Preserve => svg,
74            CssOverridePolicy::StripExistingImportant => {
75                let stripped = strip_css_important(svg.as_ref());
76                execution.checkpoint()?;
77                Cow::Owned(stripped)
78            }
79        };
80        let css = decode_mermaid_css_hash_placeholders(&self.css, execution)?;
81        let scope = css_scope(ctx.svg_id(), execution)?;
82        let plan = InjectionPlan::locate(base.as_ref(), self.merge_into_existing_style, execution)?;
83
84        let projected_css_bytes =
85            rewrite::projected_css_bytes(css.as_ref(), scope.as_deref(), execution)?;
86        let projected_svg_bytes = plan
87            .projected_svg_bytes(base.len(), projected_css_bytes)
88            .ok_or_else(|| execution.svg_byte_count_overflow())?;
89        execution.preflight_svg_byte_count(projected_svg_bytes)?;
90        execution.checkpoint()?;
91
92        let mut output = String::new();
93        output
94            .try_reserve_exact(projected_svg_bytes)
95            .map_err(|error| {
96                Error::svg_postprocess(
97                    "scoped-css",
98                    format!("failed to allocate scoped SVG: {error}"),
99                )
100            })?;
101        output.push_str(&base[..plan.insertion]);
102        if plan.wrapped {
103            output.push_str(STYLE_OPEN);
104        }
105        rewrite::materialize_css(css.as_ref(), scope.as_deref(), &mut output, execution)?;
106        if plan.wrapped {
107            output.push_str(STYLE_CLOSE);
108        }
109        output.push_str(&base[plan.insertion..]);
110        execution.checkpoint()?;
111        if output.len() != projected_svg_bytes {
112            return Err(Error::svg_postprocess(
113                "scoped-css",
114                "scoped SVG byte projection changed during materialization",
115            ));
116        }
117        Ok(Cow::Owned(output))
118    }
119}
120
121fn decode_mermaid_css_hash_placeholders<'a>(
122    css: &'a str,
123    execution: SvgPostprocessExecution<'_>,
124) -> Result<Cow<'a, str>> {
125    let mut checkpoint = || execution.checkpoint();
126    let mut has_placeholder = false;
127    for (iteration, character) in css.chars().enumerate() {
128        checkpoint_loop(iteration, &mut checkpoint)?;
129        if matches!(character, 'fl' | '¶') {
130            has_placeholder = true;
131            break;
132        }
133    }
134    checkpoint()?;
135    if !has_placeholder {
136        return Ok(Cow::Borrowed(css));
137    }
138
139    let mut decoded = String::new();
140    decoded.try_reserve_exact(css.len()).map_err(|error| {
141        Error::svg_postprocess(
142            "scoped-css",
143            format!("failed to allocate decoded scoped CSS: {error}"),
144        )
145    })?;
146    let mut cursor = 0usize;
147    let mut iteration = 0usize;
148    while cursor < css.len() {
149        checkpoint_loop(iteration, &mut checkpoint)?;
150        iteration = iteration.wrapping_add(1);
151        let tail = &css[cursor..];
152        if tail.starts_with("fl°°") {
153            decoded.push('#');
154            cursor += "fl°°".len();
155        } else if tail.starts_with("fl°") {
156            decoded.push('#');
157            cursor += "fl°".len();
158        } else if tail.starts_with("¶ß") {
159            decoded.push(';');
160            cursor += "¶ß".len();
161        } else {
162            let character = tail
163                .chars()
164                .next()
165                .expect("cursor remains on a UTF-8 character boundary");
166            decoded.push(character);
167            cursor += character.len_utf8();
168        }
169    }
170    checkpoint()?;
171    Ok(Cow::Owned(decoded))
172}
173
174fn css_scope(
175    svg_id: Option<&str>,
176    execution: SvgPostprocessExecution<'_>,
177) -> Result<Option<String>> {
178    let Some(svg_id) = svg_id else {
179        return Ok(None);
180    };
181    let mut checkpoint = || execution.checkpoint();
182    let svg_id = trim_with_checkpoints(svg_id, &mut checkpoint)?;
183    if svg_id.is_empty() {
184        return Ok(None);
185    }
186
187    let mut checkpoint = || execution.checkpoint();
188    let mut escaped_bytes = 1usize;
189    for (iteration, character) in svg_id.chars().enumerate() {
190        checkpoint_loop(iteration, &mut checkpoint)?;
191        escaped_bytes = escaped_bytes
192            .checked_add(if is_css_identifier_character(character) {
193                character.len_utf8()
194            } else {
195                1 + character.len_utf8()
196            })
197            .ok_or_else(|| execution.svg_byte_count_overflow())?;
198    }
199    checkpoint()?;
200    let mut scope = String::new();
201    scope.try_reserve_exact(escaped_bytes).map_err(|error| {
202        Error::svg_postprocess(
203            "scoped-css",
204            format!("failed to allocate scoped CSS root selector: {error}"),
205        )
206    })?;
207    scope.push('#');
208    for (iteration, character) in svg_id.chars().enumerate() {
209        checkpoint_loop(iteration, &mut checkpoint)?;
210        if !is_css_identifier_character(character) {
211            scope.push('\\');
212        }
213        scope.push(character);
214    }
215    checkpoint()?;
216    Ok(Some(scope))
217}
218
219fn is_css_identifier_character(character: char) -> bool {
220    character.is_ascii_alphanumeric() || character == '-' || character == '_'
221}
222
223#[derive(Debug, Clone, Copy)]
224struct InjectionPlan {
225    insertion: usize,
226    wrapped: bool,
227}
228
229impl InjectionPlan {
230    fn locate(
231        svg: &str,
232        merge_into_existing_style: bool,
233        execution: SvgPostprocessExecution<'_>,
234    ) -> Result<Self> {
235        let mut checkpoint = || execution.checkpoint();
236        if merge_into_existing_style
237            && let Some(insertion) = find_with_checkpoints(svg, "</style", &mut checkpoint)?
238        {
239            return Ok(Self {
240                insertion,
241                wrapped: false,
242            });
243        }
244
245        if let Some(root_start) = find_with_checkpoints(svg, "<svg", &mut checkpoint)?
246            && let Some(root_end) = find_tag_end_with_checkpoints(svg, root_start, &mut checkpoint)?
247        {
248            if let Some(style_start) = rfind_with_checkpoints(svg, "</style", &mut checkpoint)?
249                && let Some(style_end) =
250                    find_tag_end_with_checkpoints(svg, style_start, &mut checkpoint)?
251            {
252                return Ok(Self {
253                    insertion: style_end + 1,
254                    wrapped: true,
255                });
256            }
257            return Ok(Self {
258                insertion: root_end + 1,
259                wrapped: true,
260            });
261        }
262
263        Ok(Self {
264            insertion: svg.len(),
265            wrapped: true,
266        })
267    }
268
269    fn projected_svg_bytes(self, svg_bytes: usize, css_bytes: usize) -> Option<usize> {
270        let wrapper_bytes = if self.wrapped {
271            STYLE_OPEN.len() + STYLE_CLOSE.len()
272        } else {
273            0
274        };
275        svg_bytes.checked_add(css_bytes)?.checked_add(wrapper_bytes)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::environment::RenderEnvironment;
283    use crate::resources::{RenderResourcePolicy, ResourceLimitId, ResourceLimitPhase};
284    use crate::svg::pipeline::{SvgPipeline, SvgPipelinePreset, SvgPostprocessMetadata};
285    use merman_core::{CancelReason, OperationControl, OperationPhase};
286
287    fn render_session() -> crate::environment::RenderSession {
288        crate::environment::RenderEnvironment::deterministic()
289            .begin_session()
290            .unwrap()
291    }
292
293    #[test]
294    fn scoped_css_injects_after_root_svg_tag_when_no_style_exists() {
295        let svg = r#"<svg id="diagram"><rect class="node"/></svg>"#;
296        let session = render_session();
297        let out = SvgPipeline::parity()
298            .with_postprocessor(ScopedCssPostprocessor::new(
299                ".node rect, text.label { fill: red; }",
300            ))
301            .process_to_string(svg, &session)
302            .unwrap();
303
304        assert!(out.starts_with(r#"<svg id="diagram"><style"#));
305        assert!(out.contains("#diagram .node rect, #diagram text.label { fill: red; }"));
306    }
307
308    #[test]
309    fn scoped_css_injects_after_existing_style_for_cascade_order() {
310        let svg =
311            r#"<svg id="diagram"><style>#diagram .node rect { fill: red; }</style><g/></svg>"#;
312        let session = render_session();
313        let out = SvgPipeline::parity()
314            .with_postprocessor(ScopedCssPostprocessor::new(".node rect { fill: green; }"))
315            .process_to_string(svg, &session)
316            .unwrap();
317
318        let existing = out.find("fill: red").unwrap();
319        let injected = out.find("fill: green").unwrap();
320        assert!(
321            existing < injected,
322            "injected CSS should follow Mermaid CSS for cascade order: {out}"
323        );
324    }
325
326    #[test]
327    fn scoped_css_can_merge_into_mermaid_generated_stylesheet() {
328        let svg = r#"<svg id="diagram"><style>#diagram{fill:red;}</style><g/></svg>"#;
329        let session = render_session();
330        let out = SvgPipeline::parity()
331            .with_postprocessor(
332                ScopedCssPostprocessor::new(".node { fill: green; }").with_existing_style_merge(),
333            )
334            .process_to_string(svg, &session)
335            .unwrap();
336
337        assert_eq!(out.matches("<style").count(), 1);
338        assert!(out.contains("#diagram{fill:red;}#diagram .node { fill: green; }</style>"));
339        assert!(!out.contains("data-merman-postprocess"));
340    }
341
342    #[test]
343    fn scoped_css_merge_targets_mermaids_first_global_stylesheet() {
344        let svg = r#"<svg id="diagram"><style>.global{fill:red;}</style><g><style>.nested{fill:blue;}</style></g></svg>"#;
345        let session = render_session();
346        let out = SvgPipeline::parity()
347            .with_postprocessor(
348                ScopedCssPostprocessor::new(".node { fill: green; }").with_existing_style_merge(),
349            )
350            .process_to_string(svg, &session)
351            .unwrap();
352
353        assert!(out.contains(".global{fill:red;}#diagram .node { fill: green; }</style>"));
354        assert!(out.contains("<style>.nested{fill:blue;}</style>"));
355    }
356
357    #[test]
358    fn scoped_css_can_strip_existing_important_before_injection() {
359        let svg = r#"<svg id="diagram"><style>.node{fill:red !important;}</style></svg>"#;
360        let session = render_session();
361        let out = SvgPipeline::parity()
362            .with_postprocessor(
363                ScopedCssPostprocessor::new(".node { fill: green; }")
364                    .with_override_policy(CssOverridePolicy::StripExistingImportant),
365            )
366            .process_to_string(svg, &session)
367            .unwrap();
368
369        assert!(!out.contains("!important"));
370        assert!(out.contains("#diagram .node { fill: green; }"));
371    }
372
373    #[test]
374    fn scoped_css_matches_mermaid_ampersand_selector_namespace() {
375        let svg = r#"<svg id="diagram"><g/></svg>"#;
376        let session = render_session();
377        let out = SvgPipeline::parity()
378            .with_postprocessor(ScopedCssPostprocessor::new(
379                ":not(&){background:green !important}",
380            ))
381            .process_to_string(svg, &session)
382            .unwrap();
383
384        assert!(out.contains("#diagram :not(#diagram) {background:green !important}"));
385    }
386
387    #[test]
388    fn scoped_css_matches_mermaid_namespace_boundary_rules() {
389        fn normalize_css_whitespace(value: &str) -> String {
390            let mut normalized = String::with_capacity(value.len());
391            let mut pending_space = false;
392            for character in value.chars() {
393                if matches!(character, ' ' | '\n' | '\r' | '\t' | '\u{000C}') {
394                    pending_space = true;
395                    continue;
396                }
397                if pending_space && !normalized.is_empty() {
398                    normalized.push(' ');
399                }
400                pending_space = false;
401                normalized.push(character);
402            }
403            normalized
404        }
405
406        let cases = [
407            ("& ~ *", "color: red;", "#diagram #diagram ~ *"),
408            (
409                "& \n\t \r \u{000C} \r\n + *",
410                "color: red;",
411                "#diagram #diagram \n\t \r \u{000C} \r\n + *",
412            ),
413            ("& || *", "color: red;", "#diagram #diagram || *"),
414            ("&", "color: red;", "#diagram #diagram"),
415            (
416                "&",
417                "font-family: serif; font-size: 12px; fill: red;",
418                "#diagram",
419            ),
420            ("#diagram", "color: red;", "#diagram #diagram"),
421            (
422                "#diagram",
423                "font-family: serif; font-size: 12px; fill: red;",
424                "#diagram",
425            ),
426            ("& > *", "color: red;", "#diagram > *"),
427            ("& *", "color: red;", "#diagram *"),
428        ];
429
430        for (selector, body, expected) in cases {
431            let css = format!("{selector}{{{body}}}");
432            let svg = r#"<svg id="diagram"><g/></svg>"#;
433            let session = render_session();
434            let out = SvgPipeline::parity()
435                .with_postprocessor(ScopedCssPostprocessor::new(css))
436                .process_to_string(svg, &session)
437                .unwrap();
438            let normalized_output = normalize_css_whitespace(&out);
439            let normalized_expected = normalize_css_whitespace(&format!("{expected} {{{body}}}"));
440            assert!(
441                normalized_output.contains(&normalized_expected),
442                "selector: {selector:?}; output: {out}"
443            );
444        }
445    }
446
447    #[test]
448    fn scoped_css_scopes_nested_grouping_at_rules_and_drops_unsupported_rules() {
449        let svg = r#"<svg id="diagram"><g/></svg>"#;
450        let session = render_session();
451        let out = SvgPipeline::parity()
452            .with_postprocessor(ScopedCssPostprocessor::new(
453                "@layer theme; @import url('https://example.test/styles.css'); @media (max-width: 600px) { * { fill: red; } } @supports selector(h2 > p) { h2 > p { color: red; } }",
454            ))
455            .process_to_string(svg, &session)
456            .unwrap();
457
458        assert!(out.contains("@layer theme;"));
459        assert!(!out.contains("@import"));
460        assert!(out.contains("@media (max-width: 600px) {"));
461        assert!(out.contains("#diagram * { fill: red; }"));
462        assert!(out.contains("@supports selector(h2 > p) {"));
463        assert!(out.contains("#diagram h2 > p { color: red; }"));
464    }
465
466    #[test]
467    fn scoped_css_keeps_keyframes_unscoped_like_mermaid() {
468        let svg = r#"<svg id="diagram"><g/></svg>"#;
469        let session = render_session();
470        let out = SvgPipeline::parity()
471            .with_postprocessor(ScopedCssPostprocessor::new(
472                "@keyframes dash { to { stroke-dashoffset: 1000; } } .edge { animation: dash 1s; }",
473            ))
474            .process_to_string(svg, &session)
475            .unwrap();
476
477        assert!(out.contains("@keyframes dash { to { stroke-dashoffset: 1000; } }"));
478        assert!(out.contains("#diagram .edge { animation: dash 1s; }"));
479    }
480
481    #[test]
482    fn scoped_css_decodes_mermaid_hash_placeholders_as_css_hashes() {
483        let svg = r#"<svg id="diagram"><g/></svg>"#;
484        let session = render_session();
485        let out = SvgPipeline::parity()
486            .with_postprocessor(ScopedCssPostprocessor::new(".node { fill: fl°°123456¶ß }"))
487            .process_to_string(svg, &session)
488            .unwrap();
489
490        assert!(out.contains("#diagram .node { fill: #123456; }"));
491    }
492
493    #[test]
494    fn scoped_css_uses_css_tokens_for_braces_and_selector_commas() {
495        let svg = r#"<svg id="diagram"><g/></svg>"#;
496        let session = render_session();
497        let out = SvgPipeline::parity()
498            .with_postprocessor(ScopedCssPostprocessor::new(
499                r#"/* { } */ svg:not([data-x="{"]) { content: "}"; opacity: 0 } .node:is(.a, .b) { fill: red }"#,
500            ))
501            .process_to_string(svg, &session)
502            .unwrap();
503
504        assert!(
505            out.contains(r#"#diagram svg:not([data-x="{"]) { content: "}"; opacity: 0 }"#),
506            "{out}"
507        );
508        assert!(
509            out.contains("#diagram .node:is(.a, .b) { fill: red }"),
510            "{out}"
511        );
512    }
513
514    #[test]
515    fn scoped_css_escapes_style_terminators_after_tokenized_rewrite() {
516        let svg = r#"<svg id="diagram"><g/></svg>"#;
517        let session = render_session();
518        let out = SvgPipeline::parity()
519            .with_postprocessor(ScopedCssPostprocessor::new(
520                r#".node { content: "</style"; }"#,
521            ))
522            .process_to_string(svg, &session)
523            .unwrap();
524
525        assert!(out.contains(r#"content: "\3c /style";"#), "{out}");
526        assert_eq!(out.matches("</style>").count(), 1, "{out}");
527    }
528
529    #[test]
530    fn scoped_css_rejects_unclosed_rule_before_injection() {
531        let svg = r#"<svg id="diagram"><g/></svg>"#;
532        let session = render_session();
533        let error = SvgPipeline::parity()
534            .with_postprocessor(ScopedCssPostprocessor::new(
535                r#".node { content: "{"; fill: red;"#,
536            ))
537            .process_to_string(svg, &session)
538            .unwrap_err();
539
540        assert!(error.to_string().contains("unclosed"), "{error}");
541    }
542
543    #[test]
544    fn scoped_css_rejects_excessive_rule_nesting() {
545        let svg = r#"<svg id="diagram"><g/></svg>"#;
546        let session = render_session();
547        let css = format!(
548            "{} .node {{ fill: red; }} {}",
549            "@media all {".repeat(64),
550            "}".repeat(64)
551        );
552        let error = SvgPipeline::parity()
553            .with_postprocessor(ScopedCssPostprocessor::new(css))
554            .process_to_string(svg, &session)
555            .unwrap_err();
556
557        assert!(error.to_string().contains("nesting"), "{error}");
558    }
559
560    #[test]
561    fn scoped_css_preflights_exact_projected_bytes_before_materialization() {
562        let svg = r#"<svg id="diagram"><g/></svg>"#;
563        let processor =
564            ScopedCssPostprocessor::new(".node, .edge, .label { fill: red; stroke: blue; }");
565        let metadata = SvgPostprocessMetadata::from_svg(svg);
566        let unbounded_session = RenderEnvironment::deterministic()
567            .with_resource_policy(RenderResourcePolicy::unbounded_for_trusted_input())
568            .begin_session()
569            .unwrap();
570        let unbounded_context = SvgPostprocessContext::new(
571            SvgPipelinePreset::Parity,
572            0,
573            "scoped-css",
574            &metadata,
575            &unbounded_session,
576        );
577        let projected_bytes = processor
578            .process(Cow::Borrowed(svg), &unbounded_context)
579            .expect("unbounded scoped CSS should materialize")
580            .len();
581
582        let exact_policy = RenderResourcePolicy::unbounded_for_trusted_input()
583            .with_limit(ResourceLimitId::MaxSvgBytes, projected_bytes)
584            .unwrap();
585        let exact_session = RenderEnvironment::deterministic()
586            .with_resource_policy(exact_policy)
587            .begin_session()
588            .unwrap();
589        let exact_context = SvgPostprocessContext::new(
590            SvgPipelinePreset::Parity,
591            0,
592            "scoped-css",
593            &metadata,
594            &exact_session,
595        );
596        assert_eq!(
597            processor
598                .process(Cow::Borrowed(svg), &exact_context)
599                .expect("the exact SVG byte limit should admit scoped CSS")
600                .len(),
601            projected_bytes
602        );
603
604        let limited_policy = RenderResourcePolicy::unbounded_for_trusted_input()
605            .with_limit(ResourceLimitId::MaxSvgBytes, projected_bytes - 1)
606            .unwrap();
607        let limited_session = RenderEnvironment::deterministic()
608            .with_resource_policy(limited_policy)
609            .begin_session()
610            .unwrap();
611        let limited_context = SvgPostprocessContext::new(
612            SvgPipelinePreset::Parity,
613            0,
614            "scoped-css",
615            &metadata,
616            &limited_session,
617        );
618        let error = processor
619            .process(Cow::Borrowed(svg), &limited_context)
620            .unwrap_err();
621        let Error::ResourceLimitExceeded(details) = error else {
622            panic!("expected SVG byte resource rejection, got {error}");
623        };
624        assert_eq!(details.phase, ResourceLimitPhase::SvgPostprocess);
625        assert_eq!(details.limit, "max_svg_bytes");
626        assert_eq!(details.actual, projected_bytes);
627        assert_eq!(details.max, projected_bytes - 1);
628    }
629
630    #[test]
631    fn scoped_css_token_walk_observes_mid_stream_cancellation() {
632        let control = OperationControl::new();
633        let session = RenderEnvironment::deterministic()
634            .with_resource_policy(RenderResourcePolicy::unbounded_for_trusted_input())
635            .begin_session_with_control(control.clone())
636            .unwrap();
637        let css = ".node { fill: red; }".repeat(512);
638        control.cancel_after_checkpoints(2);
639
640        let error = rewrite::projected_css_bytes(
641            &css,
642            Some("#diagram"),
643            SvgPostprocessExecution::new(&session),
644        )
645        .expect_err("the CSS token walk must observe cancellation before completion");
646        let Error::Cancelled(cancelled) = error else {
647            panic!("expected structured cancellation, got {error}");
648        };
649        assert_eq!(cancelled.phase, OperationPhase::Postprocess);
650        assert_eq!(cancelled.reason, CancelReason::Requested);
651    }
652}