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