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