Skip to main content

merman_render/svg/pipeline/
mod.rs

1mod builtin;
2mod context;
3mod preset;
4
5pub(crate) use builtin::GitGraphBranchLabelBaselinePostprocessor;
6pub use builtin::{
7    CssOverridePolicy, CssOverridePostprocessor, DropNativeDuplicateFallbacksPostprocessor,
8    ForeignObjectFallbackPostprocessor, RootBackgroundPostprocessor, SanitizeCssPostprocessor,
9    SanitizeSvgAttributesPostprocessor, ScopedCssPostprocessor, StripForeignObjectPostprocessor,
10};
11pub use context::{SvgPostprocessContext, SvgPostprocessMetadata};
12pub use preset::{SvgPipelinePreset, resvg_safe_svg};
13
14use crate::{Error, Result};
15use std::borrow::Cow;
16use std::fmt;
17use std::sync::Arc;
18
19pub trait SvgPostprocessor: Send + Sync {
20    fn name(&self) -> &'static str;
21
22    fn process<'a>(
23        &self,
24        svg: Cow<'a, str>,
25        ctx: &SvgPostprocessContext<'_>,
26    ) -> Result<Cow<'a, str>>;
27}
28
29#[derive(Clone)]
30pub struct SvgPipeline {
31    preset: SvgPipelinePreset,
32    postprocessors: Vec<Arc<dyn SvgPostprocessor>>,
33}
34
35impl fmt::Debug for SvgPipeline {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        let names = self
38            .postprocessors
39            .iter()
40            .map(|pass| pass.name())
41            .collect::<Vec<_>>();
42
43        f.debug_struct("SvgPipeline")
44            .field("preset", &self.preset)
45            .field("postprocessors", &names)
46            .finish()
47    }
48}
49
50impl Default for SvgPipeline {
51    fn default() -> Self {
52        Self::parity()
53    }
54}
55
56impl SvgPipeline {
57    pub fn parity() -> Self {
58        Self::from_preset(SvgPipelinePreset::Parity)
59    }
60
61    pub fn readable() -> Self {
62        Self::from_preset(SvgPipelinePreset::Readable)
63    }
64
65    pub fn resvg_safe() -> Self {
66        Self::from_preset(SvgPipelinePreset::ResvgSafe)
67    }
68
69    pub fn from_preset(preset: SvgPipelinePreset) -> Self {
70        Self {
71            preset,
72            postprocessors: Vec::new(),
73        }
74    }
75
76    pub fn preset(&self) -> SvgPipelinePreset {
77        self.preset
78    }
79
80    pub fn with_postprocessor<P>(mut self, postprocessor: P) -> Self
81    where
82        P: SvgPostprocessor + 'static,
83    {
84        self.postprocessors.push(Arc::new(postprocessor));
85        self
86    }
87
88    pub fn with_shared_postprocessor(mut self, postprocessor: Arc<dyn SvgPostprocessor>) -> Self {
89        self.postprocessors.push(postprocessor);
90        self
91    }
92
93    pub fn push_postprocessor<P>(&mut self, postprocessor: P)
94    where
95        P: SvgPostprocessor + 'static,
96    {
97        self.postprocessors.push(Arc::new(postprocessor));
98    }
99
100    pub fn process<'a>(&self, svg: &'a str) -> Result<Cow<'a, str>> {
101        let metadata = SvgPostprocessMetadata::from_svg(svg);
102        self.process_with_metadata(svg, &metadata)
103    }
104
105    pub fn process_with_metadata<'a>(
106        &self,
107        svg: &'a str,
108        metadata: &SvgPostprocessMetadata,
109    ) -> Result<Cow<'a, str>> {
110        let mut current = preset::apply_preset(self.preset, svg);
111
112        for (index, postprocessor) in self.postprocessors.iter().enumerate() {
113            let ctx =
114                SvgPostprocessContext::new(self.preset, index, postprocessor.name(), metadata);
115            current = postprocessor
116                .process(current, &ctx)
117                .map_err(|err| Error::svg_postprocess(postprocessor.name(), err.to_string()))?;
118        }
119
120        Ok(current)
121    }
122
123    pub fn process_to_string(&self, svg: &str) -> Result<String> {
124        Ok(self.process(svg)?.into_owned())
125    }
126
127    pub fn process_to_string_with_metadata(
128        &self,
129        svg: &str,
130        metadata: &SvgPostprocessMetadata,
131    ) -> Result<String> {
132        Ok(self.process_with_metadata(svg, metadata)?.into_owned())
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn parity_pipeline_preserves_svg_exactly() {
142        let svg = r#"<svg><style>@keyframes a{to{opacity:1}}</style><rect width="10"/></svg>"#;
143        let out = SvgPipeline::parity().process(svg).unwrap();
144        assert!(matches!(out, Cow::Borrowed(_)));
145        assert_eq!(out, svg);
146    }
147
148    #[test]
149    fn readable_pipeline_matches_foreign_object_fallback() {
150        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,20)"><foreignObject width="80" height="48"><div xmlns="http://www.w3.org/1999/xhtml"><p>Layer 7\nHTTP</p></div></foreignObject></g></svg>"#;
151
152        let expected = super::builtin::foreign_object::foreign_object_fallback_svg(svg);
153        let out = SvgPipeline::readable().process_to_string(svg).unwrap();
154
155        assert_eq!(out, expected);
156        assert!(out.contains(">Layer 7</text>"));
157        assert!(out.contains(">HTTP</text>"));
158    }
159
160    #[test]
161    fn resvg_safe_pipeline_strips_generic_raster_hazards() {
162        let svg = r#"<svg id="test" xmlns="http://www.w3.org/2000/svg"><style type="text/css">@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } #test :root { --bg: white; } .node rect { animation: dash 1s linear; transform: rotate(45deg); fill: red; }</style><g transform="translate(undefined,NaN)"><foreignObject width="10" height="10"><div xmlns="http://www.w3.org/1999/xhtml"><p>Hello</p></div></foreignObject><rect width="10px" height="12px" stroke="" style="fill: ; stroke: #333; transform: rotate(45deg); animation: dash 1s;"/><rect width="10px" height="" fill="hsl(240, 100%, NaN%)"/></g></svg>"#;
163
164        let out = SvgPipeline::resvg_safe().process_to_string(svg).unwrap();
165
166        assert!(!out.contains("<foreignObject"));
167        assert!(!out.contains("@keyframes"));
168        assert!(!out.contains(":root"));
169        assert!(!out.contains("animation"));
170        assert!(!out.contains("deg"));
171        assert!(!out.contains("NaN"));
172        assert!(!out.contains("undefined"));
173        assert!(!out.contains(r#"height="""#));
174        assert!(!out.contains(r#"fill="hsl"#));
175        assert!(!out.contains(r#"stroke="""#));
176        assert!(out.contains(r#"width="10""#));
177        assert!(out.contains(r#"height="12""#));
178        assert!(out.contains("stroke:#333"));
179        assert!(out.contains(">Hello</text>"));
180    }
181
182    struct AppendPass(&'static str);
183
184    impl SvgPostprocessor for AppendPass {
185        fn name(&self) -> &'static str {
186            self.0
187        }
188
189        fn process<'a>(
190            &self,
191            svg: Cow<'a, str>,
192            ctx: &SvgPostprocessContext<'_>,
193        ) -> Result<Cow<'a, str>> {
194            Ok(Cow::Owned(format!(
195                "{}<!--{}:{}:{:?}:{}:{}:{}-->",
196                svg,
197                ctx.pass_index(),
198                ctx.pass_name(),
199                ctx.preset(),
200                ctx.diagram_type().unwrap_or("none"),
201                ctx.diagram_title().unwrap_or("none"),
202                ctx.svg_id().unwrap_or("none")
203            )))
204        }
205    }
206
207    #[test]
208    fn custom_postprocessors_run_after_builtin_preset_in_order() {
209        let svg = r#"<svg><foreignObject width="10" height="10"><div><p>Hello</p></div></foreignObject></svg>"#;
210        let pipeline = SvgPipeline::readable()
211            .with_postprocessor(AppendPass("first"))
212            .with_postprocessor(AppendPass("second"));
213
214        let out = pipeline.process_to_string(svg).unwrap();
215
216        let fallback = out.find("data-merman-foreignobject").unwrap();
217        let first = out.find("<!--0:first:Readable").unwrap();
218        let second = out.find("<!--1:second:Readable").unwrap();
219        assert!(fallback < first);
220        assert!(first < second);
221    }
222
223    #[test]
224    fn custom_postprocessor_context_exposes_metadata() {
225        let svg = r#"<svg id="host-diagram"><rect width="10"/></svg>"#;
226        let metadata = SvgPostprocessMetadata::from_svg(svg)
227            .with_diagram_type("flowchart-v2")
228            .with_diagram_title("Host Diagram");
229        let pipeline = SvgPipeline::parity().with_postprocessor(AppendPass("meta"));
230
231        let out = pipeline
232            .process_to_string_with_metadata(svg, &metadata)
233            .unwrap();
234
235        assert!(out.contains("<!--0:meta:Parity:flowchart-v2:Host Diagram:host-diagram-->"));
236    }
237
238    struct ErrorPass;
239
240    impl SvgPostprocessor for ErrorPass {
241        fn name(&self) -> &'static str {
242            "error-pass"
243        }
244
245        fn process<'a>(
246            &self,
247            _svg: Cow<'a, str>,
248            _ctx: &SvgPostprocessContext<'_>,
249        ) -> Result<Cow<'a, str>> {
250            Err(Error::InvalidModel {
251                message: "boom".to_string(),
252            })
253        }
254    }
255
256    #[test]
257    fn custom_postprocessor_errors_surface_with_pass_name() {
258        let err = SvgPipeline::parity()
259            .with_postprocessor(ErrorPass)
260            .process_to_string("<svg/>")
261            .unwrap_err();
262
263        let message = err.to_string();
264        assert!(message.contains("error-pass"));
265        assert!(message.contains("boom"));
266    }
267}