merman_render/svg/pipeline/builtin/
root_background.rs1use crate::Result;
2use std::borrow::Cow;
3
4use super::util::{escape_xml_attr, 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
118fn find_quoted_attr_value_span(tag: &str, name: &str) -> Option<(usize, usize)> {
119 let bytes = tag.as_bytes();
120 let mut cursor = 0usize;
121
122 while cursor < bytes.len() {
123 let rel = tag[cursor..].find(name)?;
124 let name_start = cursor + rel;
125 let name_end = name_start + name.len();
126
127 let before_ok = name_start == 0
128 || bytes[name_start - 1].is_ascii_whitespace()
129 || bytes[name_start - 1] == b'<';
130 let after_ok = name_end == bytes.len()
131 || bytes[name_end].is_ascii_whitespace()
132 || bytes[name_end] == b'=';
133 if !before_ok || !after_ok {
134 cursor = name_end;
135 continue;
136 }
137
138 let mut i = name_end;
139 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
140 i += 1;
141 }
142 if bytes.get(i) != Some(&b'=') {
143 cursor = name_end;
144 continue;
145 }
146 i += 1;
147 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
148 i += 1;
149 }
150
151 let quote = *bytes.get(i)?;
152 if quote != b'"' && quote != b'\'' {
153 cursor = name_end;
154 continue;
155 }
156 let value_start = i + 1;
157 let rel_end = tag[value_start..].find(quote as char)?;
158 return Some((value_start, value_start + rel_end));
159 }
160
161 None
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::svg::pipeline::SvgPipeline;
168
169 #[test]
170 fn root_background_rewrites_existing_background_color() {
171 let svg =
172 r#"<svg id="diagram" style="max-width: 400px; background-color: white;"><g/></svg>"#;
173
174 let out = SvgPipeline::parity()
175 .with_postprocessor(RootBackgroundPostprocessor::new("#111827"))
176 .process_to_string(svg)
177 .unwrap();
178
179 assert_eq!(
180 out,
181 r#"<svg id="diagram" style="max-width: 400px; background-color: #111827;"><g/></svg>"#
182 );
183 }
184
185 #[test]
186 fn root_background_adds_missing_style_property() {
187 let svg = r#"<svg id="diagram" width="100%"><g/></svg>"#;
188
189 let out = SvgPipeline::parity()
190 .with_postprocessor(RootBackgroundPostprocessor::new("transparent"))
191 .process_to_string(svg)
192 .unwrap();
193
194 assert_eq!(
195 out,
196 r#"<svg id="diagram" width="100%" style="background-color: transparent;"><g/></svg>"#
197 );
198 }
199
200 #[test]
201 fn root_background_escapes_xml_attribute_value() {
202 let svg = r#"<svg id="diagram" style="max-width: 400px;"><g/></svg>"#;
203
204 let out = set_root_background_color(svg, "rgb(1, 2, 3)&");
205
206 assert!(out.contains("background-color: rgb(1, 2, 3)&;"));
207 }
208}