Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:07.248Z 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

element_fixup.rs

318 lines · 10.2 KB · rust
1//! Fixes various issues in merman's SVG output.
2//!
3//! Replaces hardcoded white backgrounds with the theme background:
4//! ```xml
5//! <!-- before --> <svg style="background-color: white">
6//! <!-- after  --> <svg style="background-color: #1e1e2e">
7//! ```
8//!
9//! Removes `<rect>` elements with missing or invalid dimensions:
10//! ```xml
11//! <!-- before --> <rect width="NaN" height="10"/>
12//! <!-- after  --> (removed)
13//! ```
14//!
15//! Replaces hardcoded text colors with the theme text color:
16//! ```xml
17//! <!-- before --> <text fill="#333">Hello</text>
18//! <!-- after  --> <text fill="#cdd6f4">Hello</text>
19//! ```
20
21use std::borrow::Cow;
22use std::fmt::Write as _;
23
24use anyhow::Result;
25use quick_xml::events::{BytesStart, Event};
26
27use crate::MermaidTheme;
28
29struct ElementFixup<I> {
30    inner: I,
31    background_css: String,
32    text_color_css: String,
33    font_family_css: String,
34    svg_seen: bool,
35    skip_rect_depth: usize,
36}
37
38fn rewrite_attr<'a>(
39    e: &BytesStart<'_>,
40    attr_name: &[u8],
41    new_value: &str,
42) -> Result<BytesStart<'a>> {
43    let name = e.name();
44    let tag = std::str::from_utf8(name.as_ref())?;
45    let mut new_elem = BytesStart::new(tag.to_owned());
46    for attr in e.attributes() {
47        let attr = attr?;
48        if attr.key.local_name().as_ref() == attr_name {
49            let local_name = attr.key.local_name();
50            let key = std::str::from_utf8(local_name.as_ref())?;
51            new_elem.push_attribute((key, new_value));
52        } else {
53            new_elem.push_attribute(attr);
54        }
55    }
56    Ok(new_elem)
57}
58
59fn rewrap<'a>(event: &Event<'_>, elem: BytesStart<'a>) -> Event<'a> {
60    match event {
61        Event::Start(_) => Event::Start(elem),
62        _ => Event::Empty(elem),
63    }
64}
65
66fn is_bad_rect(e: &BytesStart) -> Result<bool> {
67    for attr_name in ["width", "height"] {
68        match e.try_get_attribute(attr_name)? {
69            None => return Ok(true),
70            Some(attr) => {
71                let val = attr.unescape_value()?;
72                let trimmed = val.trim();
73                if trimmed.is_empty() {
74                    return Ok(true);
75                }
76                if let Ok(n) = trimmed.parse::<f64>() {
77                    if !n.is_finite() || n <= 0.0 {
78                        return Ok(true);
79                    }
80                }
81            }
82        }
83    }
84    Ok(false)
85}
86
87fn is_hardcoded_text_fill(val: &str) -> bool {
88    matches!(
89        val,
90        "" | "#333" | "black" | "#000" | "#000000" | "white" | "#fff" | "#ffffff"
91    )
92}
93
94fn push_font_style(style: &mut String, font_family: &str) {
95    write!(style, "font-family: {font_family};").expect("write to String cannot fail");
96}
97
98fn font_style(font_family: &str) -> String {
99    let mut style = String::with_capacity(font_family.len() + "font-family: ;".len());
100    push_font_style(&mut style, font_family);
101    style
102}
103
104fn rewrite_background_style<'a>(style: &'a str, background_css: &str) -> Cow<'a, str> {
105    const PROPERTY: &str = "background-color:";
106
107    let Some(property_start) = style.find(PROPERTY) else {
108        return Cow::Borrowed(style);
109    };
110    let value_start = property_start + PROPERTY.len();
111    let value_end = style[value_start..]
112        .find(';')
113        .map_or(style.len(), |offset| value_start + offset);
114    let value = style[value_start..value_end].trim();
115
116    let is_white = value.eq_ignore_ascii_case("white")
117        || value.eq_ignore_ascii_case("#fff")
118        || value.eq_ignore_ascii_case("#ffffff");
119    if !is_white {
120        return Cow::Borrowed(style);
121    }
122
123    let value_len = value_end.saturating_sub(value_start);
124    let mut rewritten = String::with_capacity(
125        style
126            .len()
127            .saturating_sub(value_len)
128            .saturating_add(background_css.len()),
129    );
130    rewritten.push_str(&style[..value_start]);
131    rewritten.push_str(background_css);
132    rewritten.push_str(&style[value_end..]);
133    Cow::Owned(rewritten)
134}
135
136fn font_family_declaration_value(declaration: &str) -> Option<&str> {
137    let (property, value) = declaration.split_once(':')?;
138    property
139        .trim()
140        .eq_ignore_ascii_case("font-family")
141        .then(|| value.trim())
142}
143
144fn rewrite_font_style<'a>(style: &'a str, font_family: &str) -> Cow<'a, str> {
145    let mut font_family_declaration_count = 0;
146    let mut has_target_font_family = false;
147    for declaration in style
148        .split(';')
149        .map(str::trim)
150        .filter(|declaration| !declaration.is_empty())
151    {
152        if let Some(value) = font_family_declaration_value(declaration) {
153            font_family_declaration_count += 1;
154            has_target_font_family = value == font_family;
155        }
156    }
157
158    if font_family_declaration_count == 1 && has_target_font_family {
159        return Cow::Borrowed(style);
160    }
161
162    let mut rewritten =
163        String::with_capacity(style.len() + font_family.len() + " font-family: ;".len());
164    for declaration in style.split(';') {
165        let declaration = declaration.trim();
166        if declaration.is_empty() || font_family_declaration_value(declaration).is_some() {
167            continue;
168        }
169        if !rewritten.is_empty() {
170            rewritten.push(' ');
171        }
172        rewritten.push_str(declaration);
173        rewritten.push(';');
174    }
175    if !rewritten.is_empty() {
176        rewritten.push(' ');
177    }
178    push_font_style(&mut rewritten, font_family);
179    Cow::Owned(rewritten)
180}
181
182impl<'a, I: Iterator<Item = Result<Event<'a>>>> ElementFixup<I> {
183    fn rewrite_svg_style(&self, e: &BytesStart<'_>) -> Result<Option<BytesStart<'a>>> {
184        let Some(style) = e
185            .try_get_attribute("style")?
186            .map(|a| a.unescape_value())
187            .transpose()?
188        else {
189            return Ok(None);
190        };
191        let new_style = rewrite_background_style(&style, &self.background_css);
192        if matches!(new_style, Cow::Borrowed(_)) {
193            return Ok(None);
194        }
195
196        Ok(Some(rewrite_attr(e, b"style", &new_style)?))
197    }
198
199    fn rewrite_text_element(&self, e: &BytesStart<'_>, fix_fill: bool) -> Result<BytesStart<'a>> {
200        let name = e.name();
201        let tag = std::str::from_utf8(name.as_ref())?;
202        let mut new_elem = BytesStart::new(tag.to_owned());
203        let mut has_font_family = false;
204        let mut has_style = false;
205
206        for attr in e.attributes() {
207            let attr = attr?;
208            match attr.key.local_name().as_ref() {
209                b"fill" if fix_fill => {
210                    let val = attr.unescape_value()?;
211                    if is_hardcoded_text_fill(&val) {
212                        new_elem.push_attribute(("fill", self.text_color_css.as_str()));
213                    } else {
214                        new_elem.push_attribute(attr);
215                    }
216                }
217                b"font-family" => {
218                    has_font_family = true;
219                    new_elem.push_attribute(("font-family", self.font_family_css.as_str()));
220                }
221                b"style" => {
222                    has_style = true;
223                    let style = attr.unescape_value()?;
224                    let style = rewrite_font_style(&style, &self.font_family_css);
225                    new_elem.push_attribute(("style", style.as_ref()));
226                }
227                _ => new_elem.push_attribute(attr),
228            }
229        }
230
231        if !has_font_family {
232            new_elem.push_attribute(("font-family", self.font_family_css.as_str()));
233        }
234        if !has_style {
235            let style = font_style(&self.font_family_css);
236            new_elem.push_attribute(("style", style.as_str()));
237        }
238
239        Ok(new_elem)
240    }
241
242    fn process_event(&mut self, event: Event<'a>) -> Result<Option<Event<'a>>> {
243        match &event {
244            Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"svg" && !self.svg_seen => {
245                self.svg_seen = true;
246                if let Some(new_elem) = self.rewrite_svg_style(e)? {
247                    Ok(Some(rewrap(&event, new_elem)))
248                } else {
249                    Ok(Some(event))
250                }
251            }
252
253            Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"rect" => {
254                if is_bad_rect(e)? {
255                    if matches!(event, Event::Start(_)) {
256                        self.skip_rect_depth = 1;
257                    }
258                    Ok(None)
259                } else {
260                    Ok(Some(event))
261                }
262            }
263
264            Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => {
265                Ok(Some(rewrap(&event, self.rewrite_text_element(e, true)?)))
266            }
267
268            Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"tspan" => {
269                Ok(Some(rewrap(&event, self.rewrite_text_element(e, false)?)))
270            }
271
272            _ => Ok(Some(event)),
273        }
274    }
275}
276
277impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for ElementFixup<I> {
278    type Item = Result<Event<'a>>;
279
280    fn next(&mut self) -> Option<Self::Item> {
281        loop {
282            let event = match self.inner.next()? {
283                Ok(ev) => ev,
284                Err(e) => return Some(Err(e)),
285            };
286
287            if self.skip_rect_depth > 0 {
288                match &event {
289                    Event::Start(_) => self.skip_rect_depth += 1,
290                    Event::End(_) => self.skip_rect_depth -= 1,
291                    _ => {}
292                }
293                continue;
294            }
295
296            match self.process_event(event) {
297                Ok(Some(ev)) => return Some(Ok(ev)),
298                Ok(None) => continue,
299                Err(e) => return Some(Err(e)),
300            }
301        }
302    }
303}
304
305pub(super) fn process<'a>(
306    events: impl Iterator<Item = Result<Event<'a>>>,
307    theme: &MermaidTheme,
308) -> impl Iterator<Item = Result<Event<'a>>> {
309    ElementFixup {
310        inner: events,
311        background_css: crate::css_color(theme.background),
312        text_color_css: crate::css_color(theme.text_color),
313        font_family_css: theme.font_family.clone(),
314        svg_seen: false,
315        skip_rect_depth: 0,
316    }
317}
318
Served at tenant.openagents/omega Member data and write actions are omitted.