Skip to main content

merman_render/svg/pipeline/builtin/
foreign_object.rs

1use crate::Result;
2use crate::entities::decode_entities_minimal;
3use crate::environment::TextMeasurementPhase;
4use crate::svg::fallback::foreign_object_label_fallback_svg_text_controlled;
5use crate::text::TextMeasurer;
6use std::borrow::Cow;
7use std::collections::HashSet;
8#[cfg(test)]
9use std::convert::Infallible;
10
11use super::util::{
12    checkpoint_loop, extract_quoted_attr, find_tag_end_with_checkpoints, find_with_checkpoints,
13    rfind_with_checkpoints,
14};
15use crate::svg::pipeline::final_validation::SvgStructureMetrics;
16use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
17
18#[derive(Debug, Clone, Copy, Default)]
19pub struct ForeignObjectFallbackPostprocessor;
20
21impl SvgPostprocessor for ForeignObjectFallbackPostprocessor {
22    fn name(&self) -> &'static str {
23        "foreign-object-fallback"
24    }
25
26    fn process<'a>(
27        &self,
28        svg: Cow<'a, str>,
29        ctx: &SvgPostprocessContext<'_>,
30    ) -> Result<Cow<'a, str>> {
31        let measurer = ctx.controlled_text_measurer(TextMeasurementPhase::Wrap);
32        let structure =
33            crate::svg::pipeline::final_validation::validate_well_formed_svg_with_execution(
34                &svg,
35                ctx.execution(),
36            )?;
37        apply_foreign_object_fallback(svg, &measurer, ctx.execution(), structure)
38    }
39}
40
41pub(crate) fn apply_foreign_object_fallback<'a>(
42    svg: Cow<'a, str>,
43    text_measurer: &dyn TextMeasurer,
44    execution: crate::svg::pipeline::SvgPostprocessExecution<'_>,
45    structure: SvgStructureMetrics,
46) -> Result<Cow<'a, str>> {
47    execution.checkpoint()?;
48    if find_with_checkpoints(&svg, "<foreignObject", &mut || execution.checkpoint())?.is_none() {
49        return Ok(svg);
50    }
51    foreign_object_label_fallback_svg_text_controlled(&svg, text_measurer, execution, structure)
52        .map(Cow::Owned)
53}
54
55#[derive(Debug, Clone, Copy, Default)]
56pub struct StripForeignObjectPostprocessor;
57
58impl SvgPostprocessor for StripForeignObjectPostprocessor {
59    fn name(&self) -> &'static str {
60        "strip-foreign-object"
61    }
62
63    fn process<'a>(
64        &self,
65        svg: Cow<'a, str>,
66        ctx: &SvgPostprocessContext<'_>,
67    ) -> Result<Cow<'a, str>> {
68        apply_strip_foreign_objects(svg, || ctx.checkpoint())
69    }
70}
71
72pub(crate) fn apply_strip_foreign_objects<'a>(
73    svg: Cow<'a, str>,
74    mut checkpoint: impl FnMut() -> Result<()>,
75) -> Result<Cow<'a, str>> {
76    checkpoint()?;
77    if find_with_checkpoints(&svg, "<foreignObject", &mut checkpoint)?.is_none() {
78        return Ok(svg);
79    }
80    strip_foreign_objects_with_checkpoints(&svg, &mut checkpoint).map(Cow::Owned)
81}
82
83pub(crate) fn apply_drop_switch_native_fallbacks<'a>(
84    svg: Cow<'a, str>,
85    mut checkpoint: impl FnMut() -> Result<()>,
86) -> Result<Cow<'a, str>> {
87    let marker = r#"data-merman-foreignobject-source="switch-native-fallback""#;
88    checkpoint()?;
89    if find_with_checkpoints(&svg, marker, &mut checkpoint)?.is_none() {
90        return Ok(svg);
91    }
92    drop_switch_native_fallbacks_with_checkpoints(&svg, &mut checkpoint).map(Cow::Owned)
93}
94
95pub(crate) fn drop_switch_native_fallbacks_with_checkpoints<E>(
96    svg: &str,
97    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
98) -> std::result::Result<String, E> {
99    let marker = r#"data-merman-foreignobject-source="switch-native-fallback""#;
100    if find_with_checkpoints(svg, marker, checkpoint)?.is_none() {
101        return Ok(svg.to_string());
102    }
103    let mut out = String::with_capacity(svg.len());
104    let mut cursor = 0;
105
106    while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], marker, checkpoint)? {
107        let attr_start = cursor + rel_start;
108        let Some(group_start) = rfind_with_checkpoints(&svg[..attr_start], "<g", checkpoint)?
109        else {
110            out.push_str(&svg[cursor..attr_start]);
111            cursor = attr_start + marker.len();
112            continue;
113        };
114        if group_start < cursor {
115            out.push_str(&svg[cursor..attr_start]);
116            cursor = attr_start + marker.len();
117            continue;
118        }
119        let Some((_, group_end)) = find_matching_g_end(svg, group_start, checkpoint)? else {
120            out.push_str(&svg[cursor..attr_start]);
121            cursor = attr_start + marker.len();
122            continue;
123        };
124        out.push_str(&svg[cursor..group_start]);
125        cursor = group_end;
126    }
127
128    out.push_str(&svg[cursor..]);
129    checkpoint()?;
130    Ok(out)
131}
132
133#[cfg(test)]
134use crate::svg::foreign_object_label_fallback_svg_text;
135
136#[cfg(test)]
137pub(crate) fn foreign_object_fallback_svg(svg: &str, text_measurer: &dyn TextMeasurer) -> String {
138    foreign_object_label_fallback_svg_text(svg, text_measurer)
139}
140
141#[cfg(test)]
142fn infallible<T>(result: std::result::Result<T, Infallible>) -> T {
143    match result {
144        Ok(value) => value,
145        Err(error) => match error {},
146    }
147}
148
149#[cfg(test)]
150pub(crate) fn drop_switch_native_fallbacks(svg: &str) -> String {
151    infallible(drop_switch_native_fallbacks_with_checkpoints(
152        svg,
153        &mut || Ok::<(), Infallible>(()),
154    ))
155}
156
157pub(crate) fn strip_foreign_objects_with_checkpoints<E>(
158    svg: &str,
159    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
160) -> std::result::Result<String, E> {
161    let mut out = String::with_capacity(svg.len());
162    let mut cursor = 0;
163
164    while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], "<foreignObject", checkpoint)?
165    {
166        let start = cursor + rel_start;
167
168        let Some(open_end) = find_tag_end_with_checkpoints(svg, start, checkpoint)? else {
169            out.push_str(&svg[cursor..]);
170            return Ok(out);
171        };
172        let fo_tag = &svg[start..=open_end];
173        let switch_wrapper = find_wrapping_switch(svg, cursor, start, open_end, checkpoint)?;
174
175        if let Some((switch_start, switch_close_start, switch_close_end)) = switch_wrapper {
176            // This foreignObject is part of a <switch> element with native SVG fallback text.
177            // Unwrap the <switch>: remove <switch> + <foreignObject>, keep sibling <text>
178            // fallback elements.
179            out.push_str(&svg[cursor..switch_start]);
180            if !fo_tag.trim_end().ends_with("/>") {
181                let fo_close_start = open_end + 1;
182                if let Some(fo_close_rel) =
183                    find_with_checkpoints(&svg[fo_close_start..], "</foreignObject>", checkpoint)?
184                {
185                    let after_fo = fo_close_start + fo_close_rel + "</foreignObject>".len();
186                    out.push_str(&svg[after_fo..switch_close_start]);
187                }
188            }
189            cursor = switch_close_end;
190            continue;
191        }
192
193        out.push_str(&svg[cursor..start]);
194
195        if fo_tag.trim_end().ends_with("/>") {
196            cursor = open_end + 1;
197            continue;
198        }
199
200        let close_start = open_end + 1;
201        let Some(rel_close) =
202            find_with_checkpoints(&svg[close_start..], "</foreignObject>", checkpoint)?
203        else {
204            cursor = open_end + 1;
205            continue;
206        };
207        cursor = close_start + rel_close + "</foreignObject>".len();
208    }
209
210    out.push_str(&svg[cursor..]);
211    checkpoint()?;
212    Ok(out)
213}
214
215#[cfg(test)]
216pub(crate) fn strip_foreign_objects(svg: &str) -> String {
217    infallible(strip_foreign_objects_with_checkpoints(svg, &mut || {
218        Ok::<(), Infallible>(())
219    }))
220}
221
222fn find_wrapping_switch<E>(
223    svg: &str,
224    cursor: usize,
225    foreign_object_start: usize,
226    foreign_object_open_end: usize,
227    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
228) -> std::result::Result<Option<(usize, usize, usize)>, E> {
229    let Some(switch_start) =
230        find_wrapping_switch_start(svg, cursor, foreign_object_start, checkpoint)?
231    else {
232        return Ok(None);
233    };
234    if find_with_checkpoints(
235        &svg[switch_start..foreign_object_start],
236        "</switch>",
237        checkpoint,
238    )?
239    .is_some()
240    {
241        return Ok(None);
242    }
243
244    let foreign_object_end = if svg[foreign_object_start..=foreign_object_open_end]
245        .trim_end()
246        .ends_with("/>")
247    {
248        foreign_object_open_end + 1
249    } else {
250        let close_search_start = foreign_object_open_end + 1;
251        let Some(relative) =
252            find_with_checkpoints(&svg[close_search_start..], "</foreignObject>", checkpoint)?
253        else {
254            return Ok(None);
255        };
256        close_search_start + relative + "</foreignObject>".len()
257    };
258
259    let Some(switch_close_relative) =
260        find_with_checkpoints(&svg[foreign_object_end..], "</switch>", checkpoint)?
261    else {
262        return Ok(None);
263    };
264    let switch_close_start = foreign_object_end + switch_close_relative;
265    if find_with_checkpoints(
266        &svg[foreign_object_end..switch_close_start],
267        "<text",
268        checkpoint,
269    )?
270    .is_none()
271    {
272        return Ok(None);
273    }
274
275    Ok(Some((
276        switch_start,
277        switch_close_start,
278        switch_close_start + "</switch>".len(),
279    )))
280}
281
282fn find_wrapping_switch_start<E>(
283    svg: &str,
284    cursor: usize,
285    before: usize,
286    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
287) -> std::result::Result<Option<usize>, E> {
288    let mut search_end = before;
289    while search_end > cursor {
290        let Some(rel_start) =
291            rfind_with_checkpoints(&svg[cursor..search_end], "<switch", checkpoint)?
292        else {
293            return Ok(None);
294        };
295        let start = cursor + rel_start;
296        let Some(open_end) = find_tag_end_with_checkpoints(svg, start, checkpoint)? else {
297            return Ok(None);
298        };
299        if open_end >= before {
300            search_end = start;
301            continue;
302        }
303
304        let tag = &svg[start..=open_end];
305        if is_start_switch_tag(tag) {
306            return Ok(Some(start));
307        }
308
309        search_end = start;
310    }
311    Ok(None)
312}
313
314pub(crate) fn drop_native_duplicate_fallbacks_with_checkpoints<E>(
315    svg: &str,
316    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
317) -> std::result::Result<String, E> {
318    let native_text = collect_native_text_contents(svg, checkpoint)?;
319    if native_text.is_empty() {
320        return Ok(svg.to_string());
321    }
322
323    let mut out = String::with_capacity(svg.len());
324    let mut cursor = 0;
325    let marker = r#"data-merman-foreignobject="fallback""#;
326    while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], marker, checkpoint)? {
327        let attr_start = cursor + rel_start;
328        let Some(group_start) = rfind_with_checkpoints(&svg[..attr_start], "<g", checkpoint)?
329        else {
330            out.push_str(&svg[cursor..attr_start]);
331            cursor = attr_start;
332            continue;
333        };
334        if group_start < cursor {
335            out.push_str(&svg[cursor..attr_start]);
336            cursor = attr_start;
337            continue;
338        }
339        let Some((close_start, group_end)) = find_matching_g_end(svg, group_start, checkpoint)?
340        else {
341            out.push_str(&svg[cursor..attr_start]);
342            cursor = attr_start;
343            continue;
344        };
345        let Some(open_end) = find_tag_end_with_checkpoints(svg, group_start, checkpoint)? else {
346            out.push_str(&svg[cursor..attr_start]);
347            cursor = attr_start;
348            continue;
349        };
350
351        let fallback_text = normalize_text_content(&svg[open_end + 1..close_start], checkpoint)?;
352        if native_text.contains(fallback_text.trim()) {
353            out.push_str(&svg[cursor..group_start]);
354        } else {
355            out.push_str(&svg[cursor..group_end]);
356        }
357        cursor = group_end;
358    }
359
360    out.push_str(&svg[cursor..]);
361    checkpoint()?;
362    Ok(out)
363}
364
365#[cfg(test)]
366pub(crate) fn drop_native_duplicate_fallbacks(svg: &str) -> String {
367    infallible(drop_native_duplicate_fallbacks_with_checkpoints(
368        svg,
369        &mut || Ok::<(), Infallible>(()),
370    ))
371}
372
373fn collect_native_text_contents<E>(
374    svg: &str,
375    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
376) -> std::result::Result<HashSet<String>, E> {
377    let mut contents = HashSet::new();
378    let mut cursor = 0;
379    while let Some(rel_start) = find_with_checkpoints(&svg[cursor..], "<text", checkpoint)? {
380        let start = cursor + rel_start;
381        let Some(open_end) = find_tag_end_with_checkpoints(svg, start, checkpoint)? else {
382            break;
383        };
384        let tag = &svg[start..=open_end];
385        if text_tag_is_fallback(tag) || tag.trim_end().ends_with("/>") {
386            cursor = open_end + 1;
387            continue;
388        }
389
390        let close_start = open_end + 1;
391        let Some(rel_close) = find_with_checkpoints(&svg[close_start..], "</text>", checkpoint)?
392        else {
393            cursor = open_end + 1;
394            continue;
395        };
396        let close = close_start + rel_close;
397        let text = normalize_text_content(&svg[close_start..close], checkpoint)?;
398        if !text.is_empty() {
399            contents.insert(text);
400        }
401        cursor = close + "</text>".len();
402    }
403    checkpoint()?;
404    Ok(contents)
405}
406
407fn text_tag_is_fallback(tag: &str) -> bool {
408    extract_quoted_attr(tag, "class").is_some_and(|classes| {
409        classes
410            .split_whitespace()
411            .any(|class| class == "merman-foreignobject-fallback-text")
412    })
413}
414
415fn normalize_text_content<E>(
416    fragment: &str,
417    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
418) -> std::result::Result<String, E> {
419    let stripped = strip_tags(fragment, checkpoint)?;
420    checkpoint()?;
421    let normalized = decode_entities_minimal(&stripped).trim().to_string();
422    checkpoint()?;
423    Ok(normalized)
424}
425
426fn strip_tags<E>(
427    fragment: &str,
428    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
429) -> std::result::Result<String, E> {
430    let mut out = String::with_capacity(fragment.len());
431    let mut in_tag = false;
432    for (iteration, ch) in fragment.chars().enumerate() {
433        checkpoint_loop(iteration, checkpoint)?;
434        match ch {
435            '<' => in_tag = true,
436            '>' => in_tag = false,
437            _ if !in_tag => out.push(ch),
438            _ => {}
439        }
440    }
441    checkpoint()?;
442    Ok(out)
443}
444
445fn find_matching_g_end<E>(
446    svg: &str,
447    group_start: usize,
448    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
449) -> std::result::Result<Option<(usize, usize)>, E> {
450    let Some(open_end) = find_tag_end_with_checkpoints(svg, group_start, checkpoint)? else {
451        return Ok(None);
452    };
453    if svg[group_start..=open_end].trim_end().ends_with("/>") {
454        return Ok(Some((group_start, open_end + 1)));
455    }
456
457    let mut depth = 1usize;
458    let mut cursor = open_end + 1;
459    while let Some(rel_tag) = find_with_checkpoints(&svg[cursor..], "<", checkpoint)? {
460        let tag_start = cursor + rel_tag;
461        let Some(tag_end) = find_tag_end_with_checkpoints(svg, tag_start, checkpoint)? else {
462            break;
463        };
464        let tag = &svg[tag_start..=tag_end];
465        if is_start_g_tag(tag) {
466            if !tag.trim_end().ends_with("/>") {
467                depth += 1;
468            }
469        } else if is_end_g_tag(tag) {
470            let Some(next_depth) = depth.checked_sub(1) else {
471                return Ok(None);
472            };
473            depth = next_depth;
474            if depth == 0 {
475                return Ok(Some((tag_start, tag_end + 1)));
476            }
477        }
478        cursor = tag_end + 1;
479    }
480    Ok(None)
481}
482
483fn is_start_g_tag(tag: &str) -> bool {
484    let bytes = tag.as_bytes();
485    tag.starts_with("<g")
486        && bytes
487            .get(2)
488            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
489}
490
491fn is_end_g_tag(tag: &str) -> bool {
492    let bytes = tag.as_bytes();
493    tag.starts_with("</g")
494        && bytes
495            .get(3)
496            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
497}
498
499fn is_start_switch_tag(tag: &str) -> bool {
500    let bytes = tag.as_bytes();
501    tag.starts_with("<switch")
502        && bytes
503            .get("<switch".len())
504            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
505        && !tag.trim_end().ends_with("/>")
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::environment::{
512        HostMeasurementResult, HostTextMeasurementRequest, HostTextMeasurer, MeasurementProfileId,
513        RenderEnvironment, TextMeasurementPhase, TextMeasurementPolicy, TextMeasurementProfile,
514        TextMeasurementProfileIdentity,
515    };
516    use crate::svg::pipeline::SvgPipeline;
517    use crate::text::{TextMeasurer, TextMetrics, TextStyle};
518    use merman_core::{OperationControl, OperationPhase};
519    use std::sync::{
520        Arc,
521        atomic::{AtomicUsize, Ordering},
522    };
523
524    fn render_session() -> crate::environment::RenderSession {
525        RenderEnvironment::deterministic().begin_session().unwrap()
526    }
527
528    struct CancellingMissingHost {
529        calls: Arc<AtomicUsize>,
530        control: OperationControl,
531    }
532
533    impl HostTextMeasurer for CancellingMissingHost {
534        fn measure(&self, _request: HostTextMeasurementRequest<'_>) -> HostMeasurementResult {
535            self.calls.fetch_add(1, Ordering::Relaxed);
536            self.control.cancel();
537            Ok(None)
538        }
539    }
540
541    struct CountingFallback(Arc<AtomicUsize>);
542
543    impl TextMeasurer for CountingFallback {
544        fn measure(&self, _text: &str, _style: &TextStyle) -> TextMetrics {
545            self.0.fetch_add(1, Ordering::Relaxed);
546            TextMetrics {
547                width: 41.0,
548                height: 16.0,
549                line_count: 1,
550            }
551        }
552    }
553
554    #[test]
555    fn controlled_foreign_object_fallback_does_not_enter_backend_after_host_cancellation() {
556        let control = OperationControl::new();
557        let host_calls = Arc::new(AtomicUsize::new(0));
558        let fallback_calls = Arc::new(AtomicUsize::new(0));
559        let fallback_identity = TextMeasurementProfileIdentity::new(
560            MeasurementProfileId::new("test.svg-fallback").unwrap(),
561            "v1",
562        )
563        .unwrap();
564        let host_identity = TextMeasurementProfileIdentity::new(
565            MeasurementProfileId::new("test.svg-host").unwrap(),
566            "v1",
567        )
568        .unwrap();
569        let policy = TextMeasurementPolicy::host_display_with_fallback(
570            host_identity,
571            Arc::new(CancellingMissingHost {
572                calls: Arc::clone(&host_calls),
573                control: control.clone(),
574            }),
575            [TextMeasurementPhase::Wrap],
576            TextMeasurementProfile::new(
577                fallback_identity,
578                Arc::new(CountingFallback(Arc::clone(&fallback_calls))),
579            ),
580        );
581        let session = RenderEnvironment::deterministic()
582            .with_text_measurement_policy(policy)
583            .begin_session_with_control(control)
584            .unwrap();
585        let svg = r#"<svg><foreignObject width="120" height="24"><div style="white-space: break-spaces; width: 120px"><p>cancel me now</p></div></foreignObject></svg>"#;
586
587        let error = SvgPipeline::readable()
588            .process_to_string(svg, &session)
589            .unwrap_err();
590        let crate::Error::Cancelled(cancelled) = error else {
591            panic!("expected structured cancellation");
592        };
593        assert_eq!(cancelled.phase, OperationPhase::Postprocess);
594        assert_eq!(host_calls.load(Ordering::Relaxed), 1);
595        assert_eq!(fallback_calls.load(Ordering::Relaxed), 0);
596    }
597
598    #[test]
599    fn strip_foreign_object_stage_stops_during_a_long_structural_scan() {
600        let control = OperationControl::new().for_phase(OperationPhase::Postprocess);
601        let svg = format!(
602            "<svg>{}<foreignObject width=\"1\" height=\"1\"/></svg>",
603            "x".repeat(16 * 1024)
604        );
605        let mut checkpoints = 0usize;
606
607        let error = apply_strip_foreign_objects(Cow::Borrowed(svg.as_str()), || {
608            checkpoints = checkpoints.saturating_add(1);
609            if checkpoints == 5 {
610                control.cancel();
611            }
612            control.checkpoint().map_err(Into::into)
613        })
614        .unwrap_err();
615
616        let crate::Error::Cancelled(cancelled) = error else {
617            panic!("expected structured cancellation");
618        };
619        assert_eq!(cancelled.phase, OperationPhase::Postprocess);
620        assert_eq!(checkpoints, 5);
621    }
622
623    #[test]
624    fn drop_native_duplicate_fallbacks_removes_only_matching_fallback_groups() {
625        let svg = r##"<svg>
626<text class="task">Make tea</text>
627<g data-merman-foreignobject="fallback" class="dup">
628  <rect/>
629  <text class="merman-foreignobject-fallback-text">Make tea</text>
630</g>
631<g data-merman-foreignobject="fallback" class="keep">
632  <text class="merman-foreignobject-fallback-text">Only fallback</text>
633</g>
634</svg>"##;
635
636        let out = drop_native_duplicate_fallbacks(svg);
637
638        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
639        assert!(!out.contains(r#"class="dup""#));
640        assert!(out.contains(r#"class="keep""#));
641        assert!(out.contains("Only fallback"));
642    }
643
644    #[test]
645    fn fallback_text_class_scanner_handles_single_quoted_attrs() {
646        assert!(text_tag_is_fallback(
647            r#"<text class = 'label merman-foreignobject-fallback-text'>"#
648        ));
649        assert!(!text_tag_is_fallback(r#"<text class = 'label task'>"#));
650    }
651
652    #[test]
653    fn strip_foreign_objects_unwraps_switch_with_native_text_fallback() {
654        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>"##;
655        let out = strip_foreign_objects(svg);
656
657        assert!(
658            !out.contains("<foreignObject"),
659            "foreignObject should be stripped: {out}"
660        );
661        assert!(
662            !out.contains("<switch>"),
663            "switch wrapper should be removed: {out}"
664        );
665        assert!(
666            !out.contains("</switch>"),
667            "switch closing tag should be removed: {out}"
668        );
669        assert!(
670            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
671            "text fallback should be preserved: {out}"
672        );
673    }
674
675    #[test]
676    fn strip_foreign_objects_unwraps_switch_with_attrs() {
677        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>"##;
678        let out = strip_foreign_objects(svg);
679
680        assert!(
681            !out.contains("<foreignObject"),
682            "foreignObject should be stripped: {out}"
683        );
684        assert!(
685            !out.contains("<switch"),
686            "switch wrapper should be removed: {out}"
687        );
688        assert!(
689            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
690            "text fallback should be preserved: {out}"
691        );
692    }
693
694    #[test]
695    fn strip_foreign_objects_handles_switch_with_multiple_text_elements() {
696        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>"##;
697        let out = strip_foreign_objects(svg);
698
699        assert!(!out.contains("<foreignObject"), "{out}");
700        assert!(!out.contains("<switch>"), "{out}");
701        assert!(
702            out.contains(r#"<text x="40" y="15">Line 1</text>"#),
703            "{out}"
704        );
705        assert!(
706            out.contains(r#"<text x="40" y="30">Line 2</text>"#),
707            "{out}"
708        );
709    }
710
711    #[test]
712    fn resvg_safe_pipeline_preserves_switch_text_fallback() {
713        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>"##;
714        let session = render_session();
715        let out = SvgPipeline::resvg_safe()
716            .process_to_string(svg, &session)
717            .unwrap();
718
719        assert!(
720            !out.contains("<foreignObject"),
721            "foreignObject should be stripped: {out}"
722        );
723        assert!(!out.contains("<switch>"), "switch should be removed: {out}");
724        assert!(
725            out.contains("Go to work"),
726            "text fallback should survive full pipeline: {out}"
727        );
728        assert!(
729            !out.contains(r#"data-merman-foreignobject-source"#),
730            "generated fallback should be dropped: {out}"
731        );
732    }
733
734    #[test]
735    fn strip_foreign_objects_handles_journey_switch_pattern() {
736        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>"##;
737        let out = strip_foreign_objects(svg);
738
739        assert!(
740            !out.contains("<foreignObject"),
741            "foreignObject should be stripped: {out}"
742        );
743        assert!(!out.contains("<switch>"), "switch should be removed: {out}");
744        assert!(
745            out.contains("Go to work"),
746            "section text should be preserved: {out}"
747        );
748        assert!(
749            out.contains(r#"<text x="425" y="75""#),
750            "text element should be preserved: {out}"
751        );
752    }
753
754    #[test]
755    fn strip_foreign_objects_still_works_without_switch() {
756        let svg = r#"<svg><foreignObject width="80" height="24"><div>Hello</div></foreignObject><text>World</text></svg>"#;
757        let out = strip_foreign_objects(svg);
758
759        assert!(!out.contains("<foreignObject"), "{out}");
760        assert!(out.contains("<text>World</text>"), "{out}");
761    }
762
763    #[test]
764    fn drop_switch_native_fallbacks_removes_tagged_groups() {
765        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>"##;
766        let out = drop_switch_native_fallbacks(svg);
767
768        assert!(
769            !out.contains("switch-native-fallback"),
770            "tagged fallback group should be removed: {out}"
771        );
772        assert!(
773            out.contains("Other label"),
774            "non-switch fallback should be kept: {out}"
775        );
776        assert!(
777            out.contains(r#"<text x="60" y="45">Make tea</text>"#),
778            "native text should remain: {out}"
779        );
780    }
781
782    #[test]
783    fn resvg_safe_can_optionally_drop_native_duplicate_fallbacks() {
784        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
785<text class="task">Make tea</text>
786<g transform="translate(0,0)">
787  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Make tea</p></div></foreignObject>
788</g>
789<g transform="translate(0,40)">
790  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Only fallback</p></div></foreignObject>
791</g>
792</svg>"##;
793
794        let session = render_session();
795        let out = SvgPipeline::resvg_safe()
796            .with_drop_native_duplicate_fallbacks(true)
797            .process_to_string(svg, &session)
798            .unwrap();
799
800        assert!(!out.contains("<foreignObject"));
801        assert_eq!(
802            out.matches(r#"data-merman-foreignobject="fallback""#)
803                .count(),
804            1,
805            "{out}"
806        );
807        assert!(out.contains("Only fallback"));
808        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
809    }
810}