Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:35:53.947Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

strip_foreignobject.rs

207 lines · 7.3 KB · rust
1//! Strips `<foreignObject>` elements and de-duplicates redundant fallback label
2//! groups from the SVG.
3//!
4//! merman's raster-safe SVG pipeline converts `<foreignObject>` labels into
5//! native `<text>` fallback groups (`data-merman-foreignobject="fallback"`) and
6//! removes the original `<foreignObject>` elements before Zed-specific
7//! post-processing runs. For most diagram types the fallback `<text>` group is
8//! the *only* remaining copy of a label, so it must be preserved.
9//!
10//! However, some diagram types (e.g. user journey) render a label as a real
11//! native `<text>` element *and* also wrap it in a `<foreignObject>` that
12//! becomes a fallback group. Rasterizing both produces the same label twice at
13//! slightly different sizes. To avoid that, we drop a fallback group only when
14//! its text content duplicates a native (non-fallback) `<text>` elsewhere in the
15//! diagram. Fallback groups whose label has no native twin are always kept.
16//!
17//! ```xml
18//! <!-- before -->
19//! <text class="task">Make tea</text>
20//! <g data-merman-foreignobject="fallback"><text>Make tea</text></g>
21//!
22//! <!-- after -->
23//! <text class="task">Make tea</text>
24//! ```
25
26use std::collections::{HashSet, VecDeque};
27
28use anyhow::Result;
29use quick_xml::Reader;
30use quick_xml::events::Event;
31
32use super::ReaderIter;
33
34const FALLBACK_TEXT_CLASS: &str = "merman-foreignobject-fallback-text";
35
36/// Collects the trimmed text content of every native (non-fallback) `<text>`
37/// element. Fallback groups whose label matches one of these are duplicates.
38fn collect_native_text_contents(svg: &str) -> HashSet<String> {
39    let mut contents = HashSet::new();
40    let mut reader = Reader::from_str(svg);
41    reader.config_mut().check_end_names = false;
42
43    let mut in_native_text = false;
44    let mut current = String::new();
45    for event in ReaderIter::new(reader) {
46        match event {
47            Ok(Event::Start(e)) if e.name().as_ref() == b"text" => {
48                in_native_text = !is_fallback_text(&e);
49                current.clear();
50            }
51            Ok(Event::Text(t)) if in_native_text => {
52                if let Ok(decoded) = t.decode() {
53                    current.push_str(&decoded);
54                }
55            }
56            Ok(Event::End(e)) if e.name().as_ref() == b"text" => {
57                if in_native_text {
58                    let trimmed = current.trim();
59                    if !trimmed.is_empty() {
60                        contents.insert(trimmed.to_string());
61                    }
62                }
63                in_native_text = false;
64            }
65            _ => {}
66        }
67    }
68    contents
69}
70
71fn is_fallback_text(e: &quick_xml::events::BytesStart<'_>) -> bool {
72    e.try_get_attribute("class")
73        .ok()
74        .flatten()
75        .and_then(|a| a.unescape_value().ok())
76        .is_some_and(|v| v.split_whitespace().any(|c| c == FALLBACK_TEXT_CLASS))
77}
78
79struct StripForeignObject<'a, I> {
80    inner: I,
81    /// Depth inside a `<foreignObject>` element being stripped.
82    foreign_depth: usize,
83    /// Trimmed contents of native `<text>` elements; fallback groups whose label
84    /// matches one of these are dropped as duplicates.
85    native_text_contents: HashSet<String>,
86    /// Buffered events of the fallback group currently being inspected, plus the
87    /// nesting depth within it and its accumulated text content.
88    buffer: Vec<Event<'a>>,
89    fallback_depth: usize,
90    buffered_text: String,
91    /// Events ready to emit (a flushed, non-duplicate fallback group).
92    output: VecDeque<Event<'a>>,
93}
94
95impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for StripForeignObject<'a, I> {
96    type Item = Result<Event<'a>>;
97
98    fn next(&mut self) -> Option<Self::Item> {
99        loop {
100            if let Some(event) = self.output.pop_front() {
101                return Some(Ok(event));
102            }
103
104            let event = match self.inner.next()? {
105                Ok(event) => event,
106                Err(e) => return Some(Err(e)),
107            };
108
109            // Strip foreignObject elements and their contents (defensive: merman
110            // already removes them, but a stray one cannot be rasterized).
111            match &event {
112                Event::Start(e) if e.name().as_ref() == b"foreignObject" => {
113                    self.foreign_depth += 1;
114                    continue;
115                }
116                Event::Start(_) if self.foreign_depth > 0 => {
117                    self.foreign_depth += 1;
118                    continue;
119                }
120                Event::End(_) if self.foreign_depth > 0 => {
121                    self.foreign_depth -= 1;
122                    continue;
123                }
124                Event::Empty(e) if e.name().as_ref() == b"foreignObject" => {
125                    continue;
126                }
127                _ if self.foreign_depth > 0 => {
128                    continue;
129                }
130                _ => {}
131            }
132
133            if self.fallback_depth > 0 {
134                self.buffer_fallback_event(event);
135                continue;
136            }
137
138            // Start buffering a fallback group so we can decide whether it is a
139            // duplicate of a native label once we have seen its text.
140            if let Event::Start(e) = &event {
141                if e.name().as_ref() == b"g" && is_fallback_group(e) {
142                    self.fallback_depth = 1;
143                    self.buffered_text.clear();
144                    self.buffer.push(event);
145                    continue;
146                }
147            }
148
149            return Some(Ok(event));
150        }
151    }
152}
153
154impl<'a, I> StripForeignObject<'a, I> {
155    fn buffer_fallback_event(&mut self, event: Event<'a>) {
156        match &event {
157            Event::Start(_) => self.fallback_depth += 1,
158            Event::End(_) => self.fallback_depth = self.fallback_depth.saturating_sub(1),
159            Event::Text(t) => {
160                if let Ok(decoded) = t.decode() {
161                    self.buffered_text.push_str(&decoded);
162                }
163            }
164            _ => {}
165        }
166        self.buffer.push(event);
167
168        if self.fallback_depth == 0 {
169            let is_duplicate = self
170                .native_text_contents
171                .contains(self.buffered_text.trim());
172            let group = std::mem::take(&mut self.buffer);
173            if !is_duplicate {
174                self.output.extend(group);
175            }
176        }
177    }
178}
179
180fn is_fallback_group(e: &quick_xml::events::BytesStart<'_>) -> bool {
181    e.try_get_attribute("data-merman-foreignobject")
182        .ok()
183        .flatten()
184        .is_some_and(|attr| attr.value.as_ref() == b"fallback")
185}
186
187pub(super) fn process<'a>(
188    inner: impl Iterator<Item = Result<Event<'a>>>,
189    svg: &str,
190) -> impl Iterator<Item = Result<Event<'a>>> {
191    // if there's no foreignobjects,
192    let native_text_contents = if svg.contains("data-merman-foreignobject=\"fallback\"") {
193        collect_native_text_contents(svg)
194    } else {
195        HashSet::new()
196    };
197    StripForeignObject {
198        inner,
199        foreign_depth: 0,
200        native_text_contents,
201        buffer: Vec::new(),
202        fallback_depth: 0,
203        buffered_text: String::new(),
204        output: VecDeque::new(),
205    }
206}
207
Served at tenant.openagents/omega Member data and write actions are omitted.