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}