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_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)&amp;;"));
207    }
208}