//! Strips `` elements and de-duplicates redundant fallback label //! groups from the SVG. //! //! merman's raster-safe SVG pipeline converts `` labels into //! native `` fallback groups (`data-merman-foreignobject="fallback"`) and //! removes the original `` elements before Zed-specific //! post-processing runs. For most diagram types the fallback `` group is //! the *only* remaining copy of a label, so it must be preserved. //! //! However, some diagram types (e.g. user journey) render a label as a real //! native `` element *and* also wrap it in a `` that //! becomes a fallback group. Rasterizing both produces the same label twice at //! slightly different sizes. To avoid that, we drop a fallback group only when //! its text content duplicates a native (non-fallback) `` elsewhere in the //! diagram. Fallback groups whose label has no native twin are always kept. //! //! ```xml //! //! Make tea //! Make tea //! //! //! Make tea //! ``` use std::collections::{HashSet, VecDeque}; use anyhow::Result; use quick_xml::Reader; use quick_xml::events::Event; use super::ReaderIter; const FALLBACK_TEXT_CLASS: &str = "merman-foreignobject-fallback-text"; /// Collects the trimmed text content of every native (non-fallback) `` /// element. Fallback groups whose label matches one of these are duplicates. fn collect_native_text_contents(svg: &str) -> HashSet { let mut contents = HashSet::new(); let mut reader = Reader::from_str(svg); reader.config_mut().check_end_names = false; let mut in_native_text = false; let mut current = String::new(); for event in ReaderIter::new(reader) { match event { Ok(Event::Start(e)) if e.name().as_ref() == b"text" => { in_native_text = !is_fallback_text(&e); current.clear(); } Ok(Event::Text(t)) if in_native_text => { if let Ok(decoded) = t.decode() { current.push_str(&decoded); } } Ok(Event::End(e)) if e.name().as_ref() == b"text" => { if in_native_text { let trimmed = current.trim(); if !trimmed.is_empty() { contents.insert(trimmed.to_string()); } } in_native_text = false; } _ => {} } } contents } fn is_fallback_text(e: &quick_xml::events::BytesStart<'_>) -> bool { e.try_get_attribute("class") .ok() .flatten() .and_then(|a| a.unescape_value().ok()) .is_some_and(|v| v.split_whitespace().any(|c| c == FALLBACK_TEXT_CLASS)) } struct StripForeignObject<'a, I> { inner: I, /// Depth inside a `` element being stripped. foreign_depth: usize, /// Trimmed contents of native `` elements; fallback groups whose label /// matches one of these are dropped as duplicates. native_text_contents: HashSet, /// Buffered events of the fallback group currently being inspected, plus the /// nesting depth within it and its accumulated text content. buffer: Vec>, fallback_depth: usize, buffered_text: String, /// Events ready to emit (a flushed, non-duplicate fallback group). output: VecDeque>, } impl<'a, I: Iterator>>> Iterator for StripForeignObject<'a, I> { type Item = Result>; fn next(&mut self) -> Option { loop { if let Some(event) = self.output.pop_front() { return Some(Ok(event)); } let event = match self.inner.next()? { Ok(event) => event, Err(e) => return Some(Err(e)), }; // Strip foreignObject elements and their contents (defensive: merman // already removes them, but a stray one cannot be rasterized). match &event { Event::Start(e) if e.name().as_ref() == b"foreignObject" => { self.foreign_depth += 1; continue; } Event::Start(_) if self.foreign_depth > 0 => { self.foreign_depth += 1; continue; } Event::End(_) if self.foreign_depth > 0 => { self.foreign_depth -= 1; continue; } Event::Empty(e) if e.name().as_ref() == b"foreignObject" => { continue; } _ if self.foreign_depth > 0 => { continue; } _ => {} } if self.fallback_depth > 0 { self.buffer_fallback_event(event); continue; } // Start buffering a fallback group so we can decide whether it is a // duplicate of a native label once we have seen its text. if let Event::Start(e) = &event { if e.name().as_ref() == b"g" && is_fallback_group(e) { self.fallback_depth = 1; self.buffered_text.clear(); self.buffer.push(event); continue; } } return Some(Ok(event)); } } } impl<'a, I> StripForeignObject<'a, I> { fn buffer_fallback_event(&mut self, event: Event<'a>) { match &event { Event::Start(_) => self.fallback_depth += 1, Event::End(_) => self.fallback_depth = self.fallback_depth.saturating_sub(1), Event::Text(t) => { if let Ok(decoded) = t.decode() { self.buffered_text.push_str(&decoded); } } _ => {} } self.buffer.push(event); if self.fallback_depth == 0 { let is_duplicate = self .native_text_contents .contains(self.buffered_text.trim()); let group = std::mem::take(&mut self.buffer); if !is_duplicate { self.output.extend(group); } } } } fn is_fallback_group(e: &quick_xml::events::BytesStart<'_>) -> bool { e.try_get_attribute("data-merman-foreignobject") .ok() .flatten() .is_some_and(|attr| attr.value.as_ref() == b"fallback") } pub(super) fn process<'a>( inner: impl Iterator>>, svg: &str, ) -> impl Iterator>> { // if there's no foreignobjects, let native_text_contents = if svg.contains("data-merman-foreignobject=\"fallback\"") { collect_native_text_contents(svg) } else { HashSet::new() }; StripForeignObject { inner, foreign_depth: 0, native_text_contents, buffer: Vec::new(), fallback_depth: 0, buffered_text: String::new(), output: VecDeque::new(), } }