merman_render/svg/pipeline/builtin/
root_background.rs1use crate::Result;
2use std::borrow::Cow;
3
4use super::util::{escape_xml_attr, find_quoted_attr_value_span, find_tag_end};
5use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RootBackgroundPostprocessor {
9 background_color: String,
10}
11
12impl RootBackgroundPostprocessor {
13 pub fn new(background_color: impl Into<String>) -> Self {
14 Self {
15 background_color: background_color.into(),
16 }
17 }
18
19 pub fn background_color(&self) -> &str {
20 &self.background_color
21 }
22}
23
24impl SvgPostprocessor for RootBackgroundPostprocessor {
25 fn name(&self) -> &'static str {
26 "root-background"
27 }
28
29 fn process<'a>(
30 &self,
31 svg: Cow<'a, str>,
32 _ctx: &SvgPostprocessContext<'_>,
33 ) -> Result<Cow<'a, str>> {
34 let background_color = self.background_color.trim();
35 if background_color.is_empty() || !svg.contains("<svg") {
36 return Ok(svg);
37 }
38
39 Ok(Cow::Owned(set_root_background_color(
40 svg.as_ref(),
41 background_color,
42 )))
43 }
44}
45
46pub(crate) fn set_root_background_color(svg: &str, background_color: &str) -> String {
47 let Some(svg_start) = svg.find("<svg") else {
48 return svg.to_string();
49 };
50 let Some(svg_end) = find_tag_end(svg, svg_start) else {
51 return svg.to_string();
52 };
53
54 let tag = &svg[svg_start..=svg_end];
55 let escaped_color = escape_xml_attr(background_color.trim());
56
57 if let Some((style_value_start, style_value_end)) = find_quoted_attr_value_span(tag, "style") {
58 let style = &tag[style_value_start..style_value_end];
59 let rewritten = set_background_in_style_attr(style, &escaped_color);
60 let absolute_value_start = svg_start + style_value_start;
61 let absolute_value_end = svg_start + style_value_end;
62
63 let mut out =
64 String::with_capacity(svg.len() + rewritten.len().saturating_sub(style.len()));
65 out.push_str(&svg[..absolute_value_start]);
66 out.push_str(&rewritten);
67 out.push_str(&svg[absolute_value_end..]);
68 return out;
69 }
70
71 let insert_at = if svg.as_bytes().get(svg_end.saturating_sub(1)) == Some(&b'/') {
72 svg_end - 1
73 } else {
74 svg_end
75 };
76
77 let mut out = String::with_capacity(svg.len() + escaped_color.len() + 34);
78 out.push_str(&svg[..insert_at]);
79 out.push_str(r#" style="background-color: "#);
80 out.push_str(&escaped_color);
81 out.push_str(r#";""#);
82 out.push_str(&svg[insert_at..]);
83 out
84}
85
86fn set_background_in_style_attr(style: &str, background_color: &str) -> String {
87 let mut declarations = Vec::new();
88 let mut replaced = false;
89
90 for declaration in style.split(';') {
91 let trimmed = declaration.trim();
92 if trimmed.is_empty() {
93 continue;
94 }
95
96 let Some((property, _value)) = trimmed.split_once(':') else {
97 declarations.push(trimmed.to_string());
98 continue;
99 };
100
101 if property.trim().eq_ignore_ascii_case("background-color") {
102 if !replaced {
103 declarations.push(format!("background-color: {background_color}"));
104 replaced = true;
105 }
106 } else {
107 declarations.push(trimmed.to_string());
108 }
109 }
110
111 if !replaced {
112 declarations.push(format!("background-color: {background_color}"));
113 }
114
115 format!("{};", declarations.join("; "))
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::svg::pipeline::SvgPipeline;
122
123 #[test]
124 fn root_background_rewrites_existing_background_color() {
125 let svg =
126 r#"<svg id="diagram" style="max-width: 400px; background-color: white;"><g/></svg>"#;
127
128 let out = SvgPipeline::parity()
129 .with_postprocessor(RootBackgroundPostprocessor::new("#111827"))
130 .process_to_string(svg)
131 .unwrap();
132
133 assert_eq!(
134 out,
135 r#"<svg id="diagram" style="max-width: 400px; background-color: #111827;"><g/></svg>"#
136 );
137 }
138
139 #[test]
140 fn root_background_adds_missing_style_property() {
141 let svg = r#"<svg id="diagram" width="100%"><g/></svg>"#;
142
143 let out = SvgPipeline::parity()
144 .with_postprocessor(RootBackgroundPostprocessor::new("transparent"))
145 .process_to_string(svg)
146 .unwrap();
147
148 assert_eq!(
149 out,
150 r#"<svg id="diagram" width="100%" style="background-color: transparent;"><g/></svg>"#
151 );
152 }
153
154 #[test]
155 fn root_background_escapes_xml_attribute_value() {
156 let svg = r#"<svg id="diagram" style="max-width: 400px;"><g/></svg>"#;
157
158 let out = set_root_background_color(svg, "rgb(1, 2, 3)&");
159
160 assert!(out.contains("background-color: rgb(1, 2, 3)&;"));
161 }
162
163 #[test]
164 fn root_background_rewrites_single_quoted_style_attr() {
165 let svg =
166 r#"<svg id="diagram" style='max-width: 400px; background-color: white;'><g/></svg>"#;
167
168 let out = set_root_background_color(svg, "#111827");
169
170 assert_eq!(
171 out,
172 r##"<svg id="diagram" style='max-width: 400px; background-color: #111827;'><g/></svg>"##
173 );
174 }
175}