Skip to repository content207 lines · 7.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:35:53.947Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
strip_foreignobject.rs
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