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