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 drop_switch_native_fallbacks(svg: &str) -> String {
71    if !svg.contains(r#"data-merman-foreignobject-source="switch-native-fallback""#) {
72        return svg.to_string();
73    }
74    let marker = r#"data-merman-foreignobject-source="switch-native-fallback""#;
75    let mut out = String::with_capacity(svg.len());
76    let mut cursor = 0;
77
78    while let Some(rel_start) = svg[cursor..].find(marker) {
79        let attr_start = cursor + rel_start;
80        let Some(group_start) = svg[..attr_start].rfind("<g") else {
81            out.push_str(&svg[cursor..attr_start]);
82            cursor = attr_start + marker.len();
83            continue;
84        };
85        if group_start < cursor {
86            out.push_str(&svg[cursor..attr_start]);
87            cursor = attr_start + marker.len();
88            continue;
89        }
90        let Some((_, group_end)) = find_matching_g_end(svg, group_start) else {
91            out.push_str(&svg[cursor..attr_start]);
92            cursor = attr_start + marker.len();
93            continue;
94        };
95        out.push_str(&svg[cursor..group_start]);
96        cursor = group_end;
97    }
98
99    out.push_str(&svg[cursor..]);
100    out
101}
102
103pub(crate) fn foreign_object_fallback_svg(svg: &str) -> String {
104    foreign_object_label_fallback_svg_text(svg)
105}
106
107pub(crate) fn strip_foreign_objects(svg: &str) -> String {
108    let mut out = String::with_capacity(svg.len());
109    let mut cursor = 0;
110
111    while let Some(rel_start) = svg[cursor..].find("<foreignObject") {
112        let start = cursor + rel_start;
113
114        let Some(open_end) = find_tag_end(svg, start) else {
115            out.push_str(&svg[cursor..]);
116            return out;
117        };
118        let fo_tag = &svg[start..=open_end];
119        let switch_wrapper = find_wrapping_switch(svg, cursor, start, open_end);
120
121        if let Some((switch_start, switch_close_start, switch_close_end)) = switch_wrapper {
122            // This foreignObject is part of a <switch> element with native SVG fallback text.
123            // Unwrap the <switch>: remove <switch> + <foreignObject>, keep sibling <text>
124            // fallback elements.
125            out.push_str(&svg[cursor..switch_start]);
126            if !fo_tag.trim_end().ends_with("/>") {
127                let fo_close_start = open_end + 1;
128                if let Some(fo_close_rel) = svg[fo_close_start..].find("</foreignObject>") {
129                    let after_fo = fo_close_start + fo_close_rel + "</foreignObject>".len();
130                    out.push_str(&svg[after_fo..switch_close_start]);
131                }
132            }
133            cursor = switch_close_end;
134            continue;
135        }
136
137        out.push_str(&svg[cursor..start]);
138
139        if fo_tag.trim_end().ends_with("/>") {
140            cursor = open_end + 1;
141            continue;
142        }
143
144        let close_start = open_end + 1;
145        let Some(rel_close) = svg[close_start..].find("</foreignObject>") else {
146            cursor = open_end + 1;
147            continue;
148        };
149        cursor = close_start + rel_close + "</foreignObject>".len();
150    }
151
152    out.push_str(&svg[cursor..]);
153    out
154}
155
156fn find_wrapping_switch(
157    svg: &str,
158    cursor: usize,
159    foreign_object_start: usize,
160    foreign_object_open_end: usize,
161) -> Option<(usize, usize, usize)> {
162    let switch_start = find_wrapping_switch_start(svg, cursor, foreign_object_start)?;
163    if svg[switch_start..foreign_object_start]
164        .find("</switch>")
165        .is_some()
166    {
167        return None;
168    }
169
170    let foreign_object_end = if svg[foreign_object_start..=foreign_object_open_end]
171        .trim_end()
172        .ends_with("/>")
173    {
174        foreign_object_open_end + 1
175    } else {
176        let close_search_start = foreign_object_open_end + 1;
177        close_search_start
178            + svg[close_search_start..].find("</foreignObject>")?
179            + "</foreignObject>".len()
180    };
181
182    let switch_close_start = foreign_object_end + svg[foreign_object_end..].find("</switch>")?;
183    if !svg[foreign_object_end..switch_close_start].contains("<text") {
184        return None;
185    }
186
187    Some((
188        switch_start,
189        switch_close_start,
190        switch_close_start + "</switch>".len(),
191    ))
192}
193
194fn find_wrapping_switch_start(svg: &str, cursor: usize, before: usize) -> Option<usize> {
195    let mut search_end = before;
196    while search_end > cursor {
197        let rel_start = svg[cursor..search_end].rfind("<switch")?;
198        let start = cursor + rel_start;
199        let open_end = find_tag_end(svg, start)?;
200        if open_end >= before {
201            search_end = start;
202            continue;
203        }
204
205        let tag = &svg[start..=open_end];
206        if is_start_switch_tag(tag) {
207            return Some(start);
208        }
209
210        search_end = start;
211    }
212    None
213}
214
215pub fn drop_native_duplicate_fallbacks(svg: &str) -> String {
216    let native_text = collect_native_text_contents(svg);
217    if native_text.is_empty() {
218        return svg.to_string();
219    }
220
221    let mut out = String::with_capacity(svg.len());
222    let mut cursor = 0;
223    while let Some(rel_start) = svg[cursor..].find(r#"data-merman-foreignobject="fallback""#) {
224        let attr_start = cursor + rel_start;
225        let Some(group_start) = svg[..attr_start].rfind("<g") else {
226            out.push_str(&svg[cursor..attr_start]);
227            cursor = attr_start;
228            continue;
229        };
230        if group_start < cursor {
231            out.push_str(&svg[cursor..attr_start]);
232            cursor = attr_start;
233            continue;
234        }
235        let Some((close_start, group_end)) = find_matching_g_end(svg, group_start) else {
236            out.push_str(&svg[cursor..attr_start]);
237            cursor = attr_start;
238            continue;
239        };
240        let Some(open_end) = find_tag_end(svg, group_start) else {
241            out.push_str(&svg[cursor..attr_start]);
242            cursor = attr_start;
243            continue;
244        };
245
246        let fallback_text = normalize_text_content(&svg[open_end + 1..close_start]);
247        if native_text.contains(fallback_text.trim()) {
248            out.push_str(&svg[cursor..group_start]);
249        } else {
250            out.push_str(&svg[cursor..group_end]);
251        }
252        cursor = group_end;
253    }
254
255    out.push_str(&svg[cursor..]);
256    out
257}
258
259fn collect_native_text_contents(svg: &str) -> HashSet<String> {
260    let mut contents = HashSet::new();
261    let mut cursor = 0;
262    while let Some(rel_start) = svg[cursor..].find("<text") {
263        let start = cursor + rel_start;
264        let Some(open_end) = find_tag_end(svg, start) else {
265            break;
266        };
267        let tag = &svg[start..=open_end];
268        if text_tag_is_fallback(tag) || tag.trim_end().ends_with("/>") {
269            cursor = open_end + 1;
270            continue;
271        }
272
273        let close_start = open_end + 1;
274        let Some(rel_close) = svg[close_start..].find("</text>") else {
275            cursor = open_end + 1;
276            continue;
277        };
278        let close = close_start + rel_close;
279        let text = normalize_text_content(&svg[close_start..close]);
280        if !text.is_empty() {
281            contents.insert(text);
282        }
283        cursor = close + "</text>".len();
284    }
285    contents
286}
287
288fn text_tag_is_fallback(tag: &str) -> bool {
289    extract_quoted_attr(tag, "class").is_some_and(|classes| {
290        classes
291            .split_whitespace()
292            .any(|class| class == "merman-foreignobject-fallback-text")
293    })
294}
295
296fn normalize_text_content(fragment: &str) -> String {
297    decode_entities_minimal(&strip_tags(fragment))
298        .trim()
299        .to_string()
300}
301
302fn strip_tags(fragment: &str) -> String {
303    let mut out = String::with_capacity(fragment.len());
304    let mut in_tag = false;
305    for ch in fragment.chars() {
306        match ch {
307            '<' => in_tag = true,
308            '>' => in_tag = false,
309            _ if !in_tag => out.push(ch),
310            _ => {}
311        }
312    }
313    out
314}
315
316fn find_matching_g_end(svg: &str, group_start: usize) -> Option<(usize, usize)> {
317    let open_end = find_tag_end(svg, group_start)?;
318    if svg[group_start..=open_end].trim_end().ends_with("/>") {
319        return Some((group_start, open_end + 1));
320    }
321
322    let mut depth = 1usize;
323    let mut cursor = open_end + 1;
324    while let Some(rel_tag) = svg[cursor..].find('<') {
325        let tag_start = cursor + rel_tag;
326        let Some(tag_end) = find_tag_end(svg, tag_start) else {
327            break;
328        };
329        let tag = &svg[tag_start..=tag_end];
330        if is_start_g_tag(tag) {
331            if !tag.trim_end().ends_with("/>") {
332                depth += 1;
333            }
334        } else if is_end_g_tag(tag) {
335            depth = depth.checked_sub(1)?;
336            if depth == 0 {
337                return Some((tag_start, tag_end + 1));
338            }
339        }
340        cursor = tag_end + 1;
341    }
342    None
343}
344
345fn is_start_g_tag(tag: &str) -> bool {
346    let bytes = tag.as_bytes();
347    tag.starts_with("<g")
348        && bytes
349            .get(2)
350            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
351}
352
353fn is_end_g_tag(tag: &str) -> bool {
354    let bytes = tag.as_bytes();
355    tag.starts_with("</g")
356        && bytes
357            .get(3)
358            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
359}
360
361fn is_start_switch_tag(tag: &str) -> bool {
362    let bytes = tag.as_bytes();
363    tag.starts_with("<switch")
364        && bytes
365            .get("<switch".len())
366            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
367        && !tag.trim_end().ends_with("/>")
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::svg::pipeline::SvgPipeline;
374
375    #[test]
376    fn drop_native_duplicate_fallbacks_removes_only_matching_fallback_groups() {
377        let svg = r##"<svg>
378<text class="task">Make tea</text>
379<g data-merman-foreignobject="fallback" class="dup">
380  <rect/>
381  <text class="merman-foreignobject-fallback-text">Make tea</text>
382</g>
383<g data-merman-foreignobject="fallback" class="keep">
384  <text class="merman-foreignobject-fallback-text">Only fallback</text>
385</g>
386</svg>"##;
387
388        let out = drop_native_duplicate_fallbacks(svg);
389
390        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
391        assert!(!out.contains(r#"class="dup""#));
392        assert!(out.contains(r#"class="keep""#));
393        assert!(out.contains("Only fallback"));
394    }
395
396    #[test]
397    fn fallback_text_class_scanner_handles_single_quoted_attrs() {
398        assert!(text_tag_is_fallback(
399            r#"<text class = 'label merman-foreignobject-fallback-text'>"#
400        ));
401        assert!(!text_tag_is_fallback(r#"<text class = 'label task'>"#));
402    }
403
404    #[test]
405    fn strip_foreign_objects_unwraps_switch_with_native_text_fallback() {
406        let svg = r##"<svg><switch><foreignObject x="10" y="20" width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml">Make tea</div></foreignObject><text x="60" y="45">Make tea</text></switch></svg>"##;
407        let out = strip_foreign_objects(svg);
408
409        assert!(
410            !out.contains("<foreignObject"),
411            "foreignObject should be stripped: {out}"
412        );
413        assert!(
414            !out.contains("<switch>"),
415            "switch wrapper should be removed: {out}"
416        );
417        assert!(
418            !out.contains("</switch>"),
419            "switch closing tag should be removed: {out}"
420        );
421        assert!(
422            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
423            "text fallback should be preserved: {out}"
424        );
425    }
426
427    #[test]
428    fn strip_foreign_objects_unwraps_switch_with_attrs() {
429        let svg = r##"<svg><switch data-renderer="future"><foreignObject x="10" y="20" width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml">Make tea</div></foreignObject><text x="60" y="45">Make tea</text></switch></svg>"##;
430        let out = strip_foreign_objects(svg);
431
432        assert!(
433            !out.contains("<foreignObject"),
434            "foreignObject should be stripped: {out}"
435        );
436        assert!(
437            !out.contains("<switch"),
438            "switch wrapper should be removed: {out}"
439        );
440        assert!(
441            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
442            "text fallback should be preserved: {out}"
443        );
444    }
445
446    #[test]
447    fn strip_foreign_objects_handles_switch_with_multiple_text_elements() {
448        let svg = r##"<svg><switch><foreignObject x="0" y="0" width="80" height="40"><div xmlns="http://www.w3.org/1999/xhtml">Line 1</div></foreignObject><text x="40" y="15">Line 1</text><text x="40" y="30">Line 2</text></switch></svg>"##;
449        let out = strip_foreign_objects(svg);
450
451        assert!(!out.contains("<foreignObject"), "{out}");
452        assert!(!out.contains("<switch>"), "{out}");
453        assert!(
454            out.contains(r#"<text x="40" y="15">Line 1</text>"#),
455            "{out}"
456        );
457        assert!(
458            out.contains(r#"<text x="40" y="30">Line 2</text>"#),
459            "{out}"
460        );
461    }
462
463    #[test]
464    fn resvg_safe_pipeline_preserves_switch_text_fallback() {
465        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><switch><foreignObject x="150" y="50" width="550" height="50"><div class="journey-section" xmlns="http://www.w3.org/1999/xhtml" style="display: table; height: 100%; width: 100%;"><div class="label" style="display: table-cell; text-align: center; vertical-align: middle;">Go to work</div></div></foreignObject><text x="425" y="75" fill="#333"><tspan x="425" dy="0">Go to work</tspan></text></switch></svg>"##;
466        let out = SvgPipeline::resvg_safe().process_to_string(svg).unwrap();
467
468        assert!(
469            !out.contains("<foreignObject"),
470            "foreignObject should be stripped: {out}"
471        );
472        assert!(!out.contains("<switch>"), "switch should be removed: {out}");
473        assert!(
474            out.contains("Go to work"),
475            "text fallback should survive full pipeline: {out}"
476        );
477        assert!(
478            !out.contains(r#"data-merman-foreignobject-source"#),
479            "generated fallback should be dropped: {out}"
480        );
481    }
482
483    #[test]
484    fn strip_foreign_objects_handles_journey_switch_pattern() {
485        let svg = r##"<svg><g><rect class="section-type-0"/><switch><foreignObject x="150" y="50" width="550" height="50"><div class="journey-section section-type-0" xmlns="http://www.w3.org/1999/xhtml" style="display: table; height: 100%; width: 100%;"><div class="label" style="display: table-cell; text-align: center; vertical-align: middle;">Go to work</div></div></foreignObject><text x="425" y="75" fill="#333" class="journey-section section-type-0" style="text-anchor: middle;"><tspan x="425" dy="0">Go to work</tspan></text></switch></g></svg>"##;
486        let out = strip_foreign_objects(svg);
487
488        assert!(
489            !out.contains("<foreignObject"),
490            "foreignObject should be stripped: {out}"
491        );
492        assert!(!out.contains("<switch>"), "switch should be removed: {out}");
493        assert!(
494            out.contains("Go to work"),
495            "section text should be preserved: {out}"
496        );
497        assert!(
498            out.contains(r#"<text x="425" y="75""#),
499            "text element should be preserved: {out}"
500        );
501    }
502
503    #[test]
504    fn strip_foreign_objects_still_works_without_switch() {
505        let svg = r#"<svg><foreignObject width="80" height="24"><div>Hello</div></foreignObject><text>World</text></svg>"#;
506        let out = strip_foreign_objects(svg);
507
508        assert!(!out.contains("<foreignObject"), "{out}");
509        assert!(out.contains("<text>World</text>"), "{out}");
510    }
511
512    #[test]
513    fn drop_switch_native_fallbacks_removes_tagged_groups() {
514        let svg = r##"<svg><text x="60" y="45">Make tea</text><g data-merman-foreignobject="fallback" data-merman-foreignobject-source="switch-native-fallback" class="merman-foreignobject-fallback"><text class="merman-foreignobject-fallback-text">Make tea</text></g><g data-merman-foreignobject="fallback" class="merman-foreignobject-fallback"><text class="merman-foreignobject-fallback-text">Other label</text></g></svg>"##;
515        let out = drop_switch_native_fallbacks(svg);
516
517        assert!(
518            !out.contains("switch-native-fallback"),
519            "tagged fallback group should be removed: {out}"
520        );
521        assert!(
522            out.contains("Other label"),
523            "non-switch fallback should be kept: {out}"
524        );
525        assert!(
526            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
527            "native text should remain: {out}"
528        );
529    }
530
531    #[test]
532    fn resvg_safe_can_optionally_drop_native_duplicate_fallbacks() {
533        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
534<text class="task">Make tea</text>
535<g transform="translate(0,0)">
536  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Make tea</p></div></foreignObject>
537</g>
538<g transform="translate(0,40)">
539  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Only fallback</p></div></foreignObject>
540</g>
541</svg>"##;
542
543        let out = SvgPipeline::resvg_safe()
544            .with_postprocessor(DropNativeDuplicateFallbacksPostprocessor)
545            .process_to_string(svg)
546            .unwrap();
547
548        assert!(!out.contains("<foreignObject"));
549        assert_eq!(
550            out.matches(r#"data-merman-foreignobject="fallback""#)
551                .count(),
552            1,
553            "{out}"
554        );
555        assert!(out.contains("Only fallback"));
556        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
557    }
558}