Skip to main content

merman_render/svg/pipeline/builtin/
root_background.rs

1use 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)&amp;;"));
192    }
193
194    #[test]
195    fn root_background_preserves_custom_font_entities() {
196        let svg = r#"<svg style="--font-family: &quot;Open Sans&quot;, sans-serif;"><g/></svg>"#;
197        let out = set_root_background_color(svg, "white");
198        assert_eq!(
199            out,
200            r#"<svg style="--font-family: &quot;Open Sans&quot;, 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: &quot;Open Sans&quot;, 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: &quot;A;B&quot;; background-color: white;"><g/></svg>"#;
212        assert_eq!(
213            set_root_background_color(svg, "#112233"),
214            r##"<svg style="--font: &quot;A;B&quot;; 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}