Skip to main content

merman_render/svg/pipeline/builtin/
root_background.rs

1use 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    fn render_session() -> crate::environment::RenderSession {
124        crate::environment::RenderEnvironment::deterministic()
125            .begin_session()
126            .unwrap()
127    }
128
129    #[test]
130    fn root_background_rewrites_existing_background_color() {
131        let svg =
132            r#"<svg id="diagram" style="max-width: 400px; background-color: white;"><g/></svg>"#;
133        let session = render_session();
134
135        let out = SvgPipeline::parity()
136            .with_postprocessor(RootBackgroundPostprocessor::new("#111827"))
137            .process_to_string(svg, &session)
138            .unwrap();
139
140        assert_eq!(
141            out,
142            r#"<svg id="diagram" style="max-width: 400px; background-color: #111827;"><g/></svg>"#
143        );
144    }
145
146    #[test]
147    fn root_background_adds_missing_style_property() {
148        let svg = r#"<svg id="diagram" width="100%"><g/></svg>"#;
149        let session = render_session();
150
151        let out = SvgPipeline::parity()
152            .with_postprocessor(RootBackgroundPostprocessor::new("transparent"))
153            .process_to_string(svg, &session)
154            .unwrap();
155
156        assert_eq!(
157            out,
158            r#"<svg id="diagram" width="100%" style="background-color: transparent;"><g/></svg>"#
159        );
160    }
161
162    #[test]
163    fn root_background_escapes_xml_attribute_value() {
164        let svg = r#"<svg id="diagram" style="max-width: 400px;"><g/></svg>"#;
165
166        let out = set_root_background_color(svg, "rgb(1, 2, 3)&");
167
168        assert!(out.contains("background-color: rgb(1, 2, 3)&amp;;"));
169    }
170
171    #[test]
172    fn root_background_rewrites_single_quoted_style_attr() {
173        let svg =
174            r#"<svg id="diagram" style='max-width: 400px; background-color: white;'><g/></svg>"#;
175
176        let out = set_root_background_color(svg, "#111827");
177
178        assert_eq!(
179            out,
180            r##"<svg id="diagram" style='max-width: 400px; background-color: #111827;'><g/></svg>"##
181        );
182    }
183}