Skip to main content

merman_render/svg/pipeline/builtin/
foreign_object.rs

1use 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}