merman_render/svg/pipeline/builtin/
foreign_object.rs1use crate::Result;
2use crate::entities::decode_entities_minimal;
3use crate::svg::foreign_object_label_fallback_svg_text;
4use std::borrow::Cow;
5use std::collections::HashSet;
6
7use super::util::{extract_quoted_attr, find_tag_end};
8use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
9
10#[derive(Debug, Clone, Copy, Default)]
11pub struct ForeignObjectFallbackPostprocessor;
12
13impl SvgPostprocessor for ForeignObjectFallbackPostprocessor {
14 fn name(&self) -> &'static str {
15 "foreign-object-fallback"
16 }
17
18 fn process<'a>(
19 &self,
20 svg: Cow<'a, str>,
21 _ctx: &SvgPostprocessContext<'_>,
22 ) -> Result<Cow<'a, str>> {
23 if !svg.contains("<foreignObject") {
24 return Ok(svg);
25 }
26 Ok(Cow::Owned(foreign_object_fallback_svg(&svg)))
27 }
28}
29
30#[derive(Debug, Clone, Copy, Default)]
31pub struct StripForeignObjectPostprocessor;
32
33impl SvgPostprocessor for StripForeignObjectPostprocessor {
34 fn name(&self) -> &'static str {
35 "strip-foreign-object"
36 }
37
38 fn process<'a>(
39 &self,
40 svg: Cow<'a, str>,
41 _ctx: &SvgPostprocessContext<'_>,
42 ) -> Result<Cow<'a, str>> {
43 if !svg.contains("<foreignObject") {
44 return Ok(svg);
45 }
46 Ok(Cow::Owned(strip_foreign_objects(&svg)))
47 }
48}
49
50#[derive(Debug, Clone, Copy, Default)]
51pub struct DropNativeDuplicateFallbacksPostprocessor;
52
53impl SvgPostprocessor for DropNativeDuplicateFallbacksPostprocessor {
54 fn name(&self) -> &'static str {
55 "drop-native-duplicate-fallbacks"
56 }
57
58 fn process<'a>(
59 &self,
60 svg: Cow<'a, str>,
61 _ctx: &SvgPostprocessContext<'_>,
62 ) -> Result<Cow<'a, str>> {
63 if !svg.contains(r#"data-merman-foreignobject="fallback""#) {
64 return Ok(svg);
65 }
66 Ok(Cow::Owned(drop_native_duplicate_fallbacks(&svg)))
67 }
68}
69
70pub(crate) fn foreign_object_fallback_svg(svg: &str) -> String {
71 foreign_object_label_fallback_svg_text(svg)
72}
73
74pub(crate) fn strip_foreign_objects(svg: &str) -> String {
75 let mut out = String::with_capacity(svg.len());
76 let mut cursor = 0;
77
78 while let Some(rel_start) = svg[cursor..].find("<foreignObject") {
79 let start = cursor + rel_start;
80 out.push_str(&svg[cursor..start]);
81
82 let Some(open_end) = find_tag_end(svg, start) else {
83 out.push_str(&svg[start..]);
84 return out;
85 };
86
87 if svg[start..=open_end].trim_end().ends_with("/>") {
88 cursor = open_end + 1;
89 continue;
90 }
91
92 let close_start = open_end + 1;
93 let Some(rel_close) = svg[close_start..].find("</foreignObject>") else {
94 cursor = open_end + 1;
95 continue;
96 };
97 cursor = close_start + rel_close + "</foreignObject>".len();
98 }
99
100 out.push_str(&svg[cursor..]);
101 out
102}
103
104pub fn drop_native_duplicate_fallbacks(svg: &str) -> String {
105 let native_text = collect_native_text_contents(svg);
106 if native_text.is_empty() {
107 return svg.to_string();
108 }
109
110 let mut out = String::with_capacity(svg.len());
111 let mut cursor = 0;
112 while let Some(rel_start) = svg[cursor..].find(r#"data-merman-foreignobject="fallback""#) {
113 let attr_start = cursor + rel_start;
114 let Some(group_start) = svg[..attr_start].rfind("<g") else {
115 out.push_str(&svg[cursor..attr_start]);
116 cursor = attr_start;
117 continue;
118 };
119 if group_start < cursor {
120 out.push_str(&svg[cursor..attr_start]);
121 cursor = attr_start;
122 continue;
123 }
124 let Some((close_start, group_end)) = find_matching_g_end(svg, group_start) else {
125 out.push_str(&svg[cursor..attr_start]);
126 cursor = attr_start;
127 continue;
128 };
129 let Some(open_end) = find_tag_end(svg, group_start) else {
130 out.push_str(&svg[cursor..attr_start]);
131 cursor = attr_start;
132 continue;
133 };
134
135 let fallback_text = normalize_text_content(&svg[open_end + 1..close_start]);
136 if native_text.contains(fallback_text.trim()) {
137 out.push_str(&svg[cursor..group_start]);
138 } else {
139 out.push_str(&svg[cursor..group_end]);
140 }
141 cursor = group_end;
142 }
143
144 out.push_str(&svg[cursor..]);
145 out
146}
147
148fn collect_native_text_contents(svg: &str) -> HashSet<String> {
149 let mut contents = HashSet::new();
150 let mut cursor = 0;
151 while let Some(rel_start) = svg[cursor..].find("<text") {
152 let start = cursor + rel_start;
153 let Some(open_end) = find_tag_end(svg, start) else {
154 break;
155 };
156 let tag = &svg[start..=open_end];
157 if text_tag_is_fallback(tag) || tag.trim_end().ends_with("/>") {
158 cursor = open_end + 1;
159 continue;
160 }
161
162 let close_start = open_end + 1;
163 let Some(rel_close) = svg[close_start..].find("</text>") else {
164 cursor = open_end + 1;
165 continue;
166 };
167 let close = close_start + rel_close;
168 let text = normalize_text_content(&svg[close_start..close]);
169 if !text.is_empty() {
170 contents.insert(text);
171 }
172 cursor = close + "</text>".len();
173 }
174 contents
175}
176
177fn text_tag_is_fallback(tag: &str) -> bool {
178 extract_quoted_attr(tag, "class").is_some_and(|classes| {
179 classes
180 .split_whitespace()
181 .any(|class| class == "merman-foreignobject-fallback-text")
182 })
183}
184
185fn normalize_text_content(fragment: &str) -> String {
186 decode_entities_minimal(&strip_tags(fragment))
187 .trim()
188 .to_string()
189}
190
191fn strip_tags(fragment: &str) -> String {
192 let mut out = String::with_capacity(fragment.len());
193 let mut in_tag = false;
194 for ch in fragment.chars() {
195 match ch {
196 '<' => in_tag = true,
197 '>' => in_tag = false,
198 _ if !in_tag => out.push(ch),
199 _ => {}
200 }
201 }
202 out
203}
204
205fn find_matching_g_end(svg: &str, group_start: usize) -> Option<(usize, usize)> {
206 let open_end = find_tag_end(svg, group_start)?;
207 if svg[group_start..=open_end].trim_end().ends_with("/>") {
208 return Some((group_start, open_end + 1));
209 }
210
211 let mut depth = 1usize;
212 let mut cursor = open_end + 1;
213 while let Some(rel_tag) = svg[cursor..].find('<') {
214 let tag_start = cursor + rel_tag;
215 let Some(tag_end) = find_tag_end(svg, tag_start) else {
216 break;
217 };
218 let tag = &svg[tag_start..=tag_end];
219 if is_start_g_tag(tag) {
220 if !tag.trim_end().ends_with("/>") {
221 depth += 1;
222 }
223 } else if is_end_g_tag(tag) {
224 depth = depth.checked_sub(1)?;
225 if depth == 0 {
226 return Some((tag_start, tag_end + 1));
227 }
228 }
229 cursor = tag_end + 1;
230 }
231 None
232}
233
234fn is_start_g_tag(tag: &str) -> bool {
235 let bytes = tag.as_bytes();
236 tag.starts_with("<g")
237 && bytes
238 .get(2)
239 .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
240}
241
242fn is_end_g_tag(tag: &str) -> bool {
243 let bytes = tag.as_bytes();
244 tag.starts_with("</g")
245 && bytes
246 .get(3)
247 .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::svg::pipeline::SvgPipeline;
254
255 #[test]
256 fn drop_native_duplicate_fallbacks_removes_only_matching_fallback_groups() {
257 let svg = r##"<svg>
258<text class="task">Make tea</text>
259<g data-merman-foreignobject="fallback" class="dup">
260 <rect/>
261 <text class="merman-foreignobject-fallback-text">Make tea</text>
262</g>
263<g data-merman-foreignobject="fallback" class="keep">
264 <text class="merman-foreignobject-fallback-text">Only fallback</text>
265</g>
266</svg>"##;
267
268 let out = drop_native_duplicate_fallbacks(svg);
269
270 assert!(out.contains(r#"<text class="task">Make tea</text>"#));
271 assert!(!out.contains(r#"class="dup""#));
272 assert!(out.contains(r#"class="keep""#));
273 assert!(out.contains("Only fallback"));
274 }
275
276 #[test]
277 fn resvg_safe_can_optionally_drop_native_duplicate_fallbacks() {
278 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
279<text class="task">Make tea</text>
280<g transform="translate(0,0)">
281 <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Make tea</p></div></foreignObject>
282</g>
283<g transform="translate(0,40)">
284 <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Only fallback</p></div></foreignObject>
285</g>
286</svg>"##;
287
288 let out = SvgPipeline::resvg_safe()
289 .with_postprocessor(DropNativeDuplicateFallbacksPostprocessor)
290 .process_to_string(svg)
291 .unwrap();
292
293 assert!(!out.contains("<foreignObject"));
294 assert_eq!(
295 out.matches(r#"data-merman-foreignobject="fallback""#)
296 .count(),
297 1,
298 "{out}"
299 );
300 assert!(out.contains("Only fallback"));
301 assert!(out.contains(r#"<text class="task">Make tea</text>"#));
302 }
303}