merman_render/svg/pipeline/builtin/
root_background.rs1use crate::Result;
2use cssparser::{Delimiter, Parser};
3use std::borrow::Cow;
4
5use super::util::{escape_xml_attr, find_quoted_attr_value_span, find_tag_end};
6use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct RootBackgroundPostprocessor {
10 background_color: String,
11}
12
13impl RootBackgroundPostprocessor {
14 pub fn new(background_color: impl Into<String>) -> Self {
15 Self {
16 background_color: background_color.into(),
17 }
18 }
19
20 pub fn background_color(&self) -> &str {
21 &self.background_color
22 }
23}
24
25impl SvgPostprocessor for RootBackgroundPostprocessor {
26 fn name(&self) -> &'static str {
27 "root-background"
28 }
29
30 fn process<'a>(
31 &self,
32 svg: Cow<'a, str>,
33 _ctx: &SvgPostprocessContext<'_>,
34 ) -> Result<Cow<'a, str>> {
35 let background_color = self.background_color.trim();
36 if background_color.is_empty() || !svg.contains("<svg") {
37 return Ok(svg);
38 }
39
40 Ok(Cow::Owned(set_root_background_color(
41 svg.as_ref(),
42 background_color,
43 )))
44 }
45}
46
47pub(crate) fn set_root_background_color(svg: &str, background_color: &str) -> String {
48 let Some(svg_start) = svg.find("<svg") else {
49 return svg.to_string();
50 };
51 let Some(svg_end) = find_tag_end(svg, svg_start) else {
52 return svg.to_string();
53 };
54
55 let tag = &svg[svg_start..=svg_end];
56 let escaped_color = escape_xml_attr(background_color.trim());
57
58 if let Some((style_value_start, style_value_end)) = find_quoted_attr_value_span(tag, "style") {
59 let style = &tag[style_value_start..style_value_end];
60 let rewritten = set_background_in_style_attr(style, &escaped_color);
61 let absolute_value_start = svg_start + style_value_start;
62 let absolute_value_end = svg_start + style_value_end;
63
64 let mut out =
65 String::with_capacity(svg.len() + rewritten.len().saturating_sub(style.len()));
66 out.push_str(&svg[..absolute_value_start]);
67 out.push_str(&rewritten);
68 out.push_str(&svg[absolute_value_end..]);
69 return out;
70 }
71
72 let insert_at = if svg.as_bytes().get(svg_end.saturating_sub(1)) == Some(&b'/') {
73 svg_end - 1
74 } else {
75 svg_end
76 };
77
78 let mut out = String::with_capacity(svg.len() + escaped_color.len() + 34);
79 out.push_str(&svg[..insert_at]);
80 out.push_str(r#" style="background-color: "#);
81 out.push_str(&escaped_color);
82 out.push_str(r#";""#);
83 out.push_str(&svg[insert_at..]);
84 out
85}
86
87fn set_background_in_style_attr(style: &str, background_color: &str) -> String {
88 if !style.split(';').any(|declaration| {
89 declaration
90 .split_once(':')
91 .is_some_and(|(property, _)| property.trim().eq_ignore_ascii_case("background-color"))
92 }) {
93 let separator = if style.trim().is_empty() || style.trim_end().ends_with(';') {
94 ""
95 } else {
96 ";"
97 };
98 return format!("{style}{separator} background-color: {background_color};");
99 }
100 let decoded = merman_core::entities::decode_html_entities_to_unicode(style);
101 let background_color = merman_core::entities::decode_html_entities_to_unicode(background_color);
102 let mut parser = Parser::new(&decoded);
103 let mut declarations = Vec::new();
104 let mut replaced = false;
105
106 while !parser.is_exhausted() {
107 let declaration = parser.parse_until_after(Delimiter::Semicolon, |declaration| {
108 let start = declaration.position();
109 while declaration.next_including_whitespace().is_ok() {}
110 Ok::<_, cssparser::ParseError<()>>(declaration.slice_from(start).trim().to_string())
111 });
112 let Ok(trimmed) = declaration else {
113 continue;
114 };
115 if trimmed.is_empty() {
116 continue;
117 }
118
119 let Some((property, _value)) = trimmed.split_once(':') else {
120 declarations.push(trimmed);
121 continue;
122 };
123
124 if property.trim().eq_ignore_ascii_case("background-color") {
125 if !replaced {
126 declarations.push(format!("background-color: {background_color}"));
127 replaced = true;
128 }
129 } else {
130 declarations.push(trimmed);
131 }
132 }
133
134 if !replaced {
135 declarations.push(format!("background-color: {background_color}"));
136 }
137
138 escape_xml_attr(&format!("{};", declarations.join("; ")))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::svg::pipeline::SvgPipeline;
145
146 fn render_session() -> crate::environment::RenderSession {
147 crate::environment::RenderEnvironment::deterministic()
148 .begin_session()
149 .unwrap()
150 }
151
152 #[test]
153 fn root_background_rewrites_existing_background_color() {
154 let svg =
155 r#"<svg id="diagram" style="max-width: 400px; background-color: white;"><g/></svg>"#;
156 let session = render_session();
157
158 let out = SvgPipeline::parity()
159 .with_postprocessor(RootBackgroundPostprocessor::new("#111827"))
160 .process_to_string(svg, &session)
161 .unwrap();
162
163 assert_eq!(
164 out,
165 r#"<svg id="diagram" style="max-width: 400px; background-color: #111827;"><g/></svg>"#
166 );
167 }
168
169 #[test]
170 fn root_background_adds_missing_style_property() {
171 let svg = r#"<svg id="diagram" width="100%"><g/></svg>"#;
172 let session = render_session();
173
174 let out = SvgPipeline::parity()
175 .with_postprocessor(RootBackgroundPostprocessor::new("transparent"))
176 .process_to_string(svg, &session)
177 .unwrap();
178
179 assert_eq!(
180 out,
181 r#"<svg id="diagram" width="100%" style="background-color: transparent;"><g/></svg>"#
182 );
183 }
184
185 #[test]
186 fn root_background_escapes_xml_attribute_value() {
187 let svg = r#"<svg id="diagram" style="max-width: 400px;"><g/></svg>"#;
188
189 let out = set_root_background_color(svg, "rgb(1, 2, 3)&");
190
191 assert!(out.contains("background-color: rgb(1, 2, 3)&;"));
192 }
193
194 #[test]
195 fn root_background_preserves_custom_font_entities() {
196 let svg = r#"<svg style="--font-family: "Open Sans", sans-serif;"><g/></svg>"#;
197 let out = set_root_background_color(svg, "white");
198 assert_eq!(
199 out,
200 r#"<svg style="--font-family: "Open Sans", sans-serif; background-color: white;"><g/></svg>"#
201 );
202 let replaced = set_root_background_color(&out, "transparent");
203 assert_eq!(
204 replaced,
205 r#"<svg style="--font-family: "Open Sans", sans-serif; background-color: transparent;"><g/></svg>"#
206 );
207 }
208
209 #[test]
210 fn root_background_preserves_semicolons_in_css_values() {
211 let svg = r#"<svg style="--font: "A;B"; background-color: white;"><g/></svg>"#;
212 assert_eq!(
213 set_root_background_color(svg, "#112233"),
214 r##"<svg style="--font: "A;B"; background-color: #112233;"><g/></svg>"##
215 );
216 }
217
218 #[test]
219 fn root_background_rewrites_single_quoted_style_attr() {
220 let svg =
221 r#"<svg id="diagram" style='max-width: 400px; background-color: white;'><g/></svg>"#;
222
223 let out = set_root_background_color(svg, "#111827");
224
225 assert_eq!(
226 out,
227 r##"<svg id="diagram" style='max-width: 400px; background-color: #111827;'><g/></svg>"##
228 );
229 }
230}