merman_render/svg/pipeline/
mod.rs1mod 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}