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}