Add standalone session exports

This commit is contained in:
Georg Bauer
2026-08-31 14:07:29 +02:00
parent 5c74739f65
commit 85138c49d4
7 changed files with 1451 additions and 61 deletions

881
src/a2ui/export.rs Normal file
View File

@@ -0,0 +1,881 @@
use super::{Surface, bound_value_at, display_value, validate_surface_composition};
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
use serde_json::{Map, Value};
use std::collections::{BTreeSet, HashMap};
use std::fmt::Write;
const WIDTH: usize = 900;
const HEIGHT: usize = 720;
const COLORS: [&str; 6] = [
"#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2c440",
];
pub(crate) fn surface_svg(
surface: &Surface,
images: &HashMap<String, String>,
) -> Result<String, String> {
validate_surface_composition(surface)?;
let renderer = Renderer { surface, images };
let content = renderer.component("root", &surface.data, BTreeSet::new());
let agent = surface
.surface_properties
.get("agentDisplayName")
.and_then(Value::as_str)
.unwrap_or("A2UI");
let label = format!("{agent} surface {}", surface.id);
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" class="a2ui-snapshot" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-label="{}"><foreignObject x="0" y="0" width="{WIDTH}" height="{HEIGHT}"><div xmlns="http://www.w3.org/1999/xhtml" class="a2ui-document"><style>{}</style><header><strong>{}</strong><span>{}</span></header><main>"#,
escaped(&label),
STYLE,
escaped(agent),
escaped(&surface.id),
);
svg.push_str(&content);
svg.push_str("</main></div></foreignObject></svg>");
Ok(svg)
}
const STYLE: &str = r#"
*{box-sizing:border-box} .a2ui-document{height:100%;overflow:auto;background:#17181b;color:#e8e8ea;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;border:1px solid #34363d;border-radius:12px}.a2ui-document>header{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;padding:11px 14px;background:#202125;border-bottom:1px solid #34363d;color:#aeb0b6;font-size:12px}.a2ui-document>main{display:flex;flex-direction:column;gap:10px;padding:16px}.row,.column,.list{display:flex;gap:10px}.row,.list.horizontal{flex-direction:row;align-items:flex-start}.column,.list{flex-direction:column}.card,.map,.table{padding:13px;background:#202125;border:1px solid #34363d;border-radius:9px}.button,.chip{display:inline-block;padding:7px 11px;background:#30323a;border:1px solid #464951;border-radius:7px}.button.primary{background:#416a91}.button.borderless{background:transparent;border-color:transparent}.field{display:flex;flex-direction:column;gap:5px}.field-label,.muted,.chart-kind,.caption{color:#999ba3;font-size:12px}.input{min-height:34px;padding:7px 9px;background:#101114;border:1px solid #393b42;border-radius:6px;white-space:pre-wrap}.check{display:flex;gap:7px;align-items:center}.check-mark{width:18px;height:18px;text-align:center;border:1px solid #555862;border-radius:5px}.divider{height:1px;background:#3a3c43}.divider.vertical{width:1px;height:auto;min-height:32px}.metric-value{font-size:28px}.error{color:#ff8585}.bar{display:grid;grid-template-columns:minmax(80px,150px) 1fr auto;gap:8px;align-items:center}.bar-track{height:9px;overflow:hidden;background:#30323a;border-radius:5px}.bar-fill{height:100%;background:#75beff}.pie{width:150px;height:150px;border-radius:50%}.donut{position:relative}.donut:after{content:"";position:absolute;inset:34px;background:#17181b;border-radius:50%}.legend{display:flex;flex-wrap:wrap;gap:8px 14px}.swatch{display:inline-block;width:10px;height:10px;margin-right:5px;border-radius:2px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:7px 9px;border:1px solid #3a3c43;text-align:left}.timeline-item,.location,.mind-node{display:flex;gap:8px}.tabs>details{margin-top:7px;padding:7px 9px;border:1px solid #34363d;border-radius:7px}.tabs summary{cursor:pointer}.media{padding:10px;border:1px solid #34363d;border-radius:7px}.surface-image{display:block;max-width:100%;max-height:260px;margin:auto;border-radius:7px}.chart,.timeline,.mindmap{display:flex;flex-direction:column;gap:8px}h1,h2,h3,p{margin-top:0}pre{overflow:auto}a{color:#8bbceb}
"#;
struct Renderer<'a> {
surface: &'a Surface,
images: &'a HashMap<String, String>,
}
impl Renderer<'_> {
fn component(&self, id: &str, context: &Value, mut ancestors: BTreeSet<String>) -> String {
if !ancestors.insert(id.to_owned()) {
return format!(
"<div class=\"error\">Cyclic component {}</div>",
escaped(id)
);
}
let Some(component) = self.surface.components.get(id).and_then(Value::as_object) else {
return format!(
"<div class=\"error\">Missing component {}</div>",
escaped(id)
);
};
let kind = component
.get("component")
.and_then(Value::as_str)
.unwrap_or("Unknown");
match kind {
"Text" => {
let content = safe_markdown(&self.value(component, "text", context));
if component.get("variant").and_then(Value::as_str) == Some("caption") {
format!("<div class=\"caption\">{content}</div>")
} else {
content
}
}
"Image" => self.image(component, context),
"Video" | "AudioPlayer" => self.media(component, context, kind),
"Icon" => format!(
"<span aria-label=\"{}\">{}</span>",
escaped(&self.value(component, "name", context)),
icon(&self.value(component, "name", context))
),
"Divider" => format!(
"<div class=\"divider{}\"></div>",
if component.get("axis").and_then(Value::as_str) == Some("vertical") {
" vertical"
} else {
""
}
),
"Row" | "Column" | "List" => {
let horizontal = kind == "Row"
|| (kind == "List"
&& component.get("direction").and_then(Value::as_str)
== Some("horizontal"));
let class = if kind == "List" {
if horizontal {
"list horizontal"
} else {
"list"
}
} else if kind == "Row" {
"row"
} else {
"column"
};
format!(
"<div class=\"{class}\"{}>{}</div>",
layout_style(component),
self.children(component.get("children"), context, &ancestors)
)
}
"Card" => format!(
"<div class=\"card\">{}</div>",
self.child(component, "child", context, &ancestors)
),
"Tabs" => self.tabs(component, context, &ancestors),
"Modal" => format!(
"<details><summary>{}</summary><div class=\"card\">{}</div></details>",
self.child(component, "trigger", context, &ancestors),
self.child(component, "content", context, &ancestors)
),
"Button" => self.with_error(
component,
format!(
"<span class=\"button {}\">{}</span>",
component
.get("variant")
.and_then(Value::as_str)
.unwrap_or("default"),
self.child(component, "child", context, &ancestors)
),
),
"TextField" => self.text_field(component, context),
"CheckBox" => self.checkbox(component, context),
"Slider" => self.slider(component, context),
"DateTimeInput" => self.with_error(
component,
self.field(
&self.value(component, "label", context),
&self.value(component, "value", context),
),
),
"ChoicePicker" => self.choices(component, context),
"Chart" => self.chart(component, context),
"Table" => self.table(component, context),
"Metric" => self.metric(component, context),
"Timeline" => self.timeline(component, context),
"Map" => self.map(component, context),
"MindMap" => self.mind_map(component, context),
"Form" => format!(
"<section class=\"card column\"><strong>{}</strong>{}<span class=\"button\">{}</span></section>",
escaped(&self.value(component, "title", context)),
self.children(component.get("children"), context, &ancestors),
escaped(
component
.get("submitLabel")
.and_then(Value::as_str)
.unwrap_or("Submit")
)
),
_ => format!(
"<div class=\"error\">Unsupported component {}</div>",
escaped(kind)
),
}
}
fn value(&self, component: &Map<String, Value>, key: &str, context: &Value) -> String {
display_value(&bound_value_at(
component.get(key),
&self.surface.data,
context,
))
}
fn with_error(&self, component: &Map<String, Value>, mut content: String) -> String {
if let Some(error) = super::first_failed_check(component, &self.surface.data) {
let _ = write!(content, "<div class=\"error\">{}</div>", escaped(&error));
}
content
}
fn child(
&self,
component: &Map<String, Value>,
key: &str,
context: &Value,
ancestors: &BTreeSet<String>,
) -> String {
component
.get(key)
.and_then(Value::as_str)
.map(|id| self.component(id, context, ancestors.clone()))
.unwrap_or_default()
}
fn children(
&self,
children: Option<&Value>,
context: &Value,
ancestors: &BTreeSet<String>,
) -> String {
if let Some(children) = children.and_then(Value::as_array) {
return children
.iter()
.filter_map(Value::as_str)
.map(|id| self.weighted_component(id, context, ancestors))
.collect();
}
let Some(template) = children.and_then(Value::as_object) else {
return String::new();
};
let id = template
.get("componentId")
.and_then(Value::as_str)
.unwrap_or("");
let path = template.get("path").and_then(Value::as_str).unwrap_or("/");
self.surface
.data
.pointer(path)
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(|(index, item)| {
super::with_template_index(index, || self.weighted_component(id, item, ancestors))
})
.collect()
}
fn weighted_component(
&self,
id: &str,
context: &Value,
ancestors: &BTreeSet<String>,
) -> String {
let content = self.component(id, context, ancestors.clone());
self.surface
.components
.get(id)
.and_then(|component| component.get("weight"))
.and_then(Value::as_f64)
.filter(|weight| weight.is_finite() && *weight > 0.0)
.map_or(content.clone(), |weight| {
format!("<div style=\"flex:{weight} 1 0\">{content}</div>")
})
}
fn tabs(
&self,
component: &Map<String, Value>,
context: &Value,
ancestors: &BTreeSet<String>,
) -> String {
let mut output = String::from("<div class=\"tabs\">");
for (index, tab) in component
.get("tabs")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let title = display_value(&bound_value_at(
tab.get("title"),
&self.surface.data,
context,
));
let child = tab.get("child").and_then(Value::as_str).unwrap_or("");
let _ = write!(
output,
"<details{}><summary>{}</summary>{}</details>",
if index == 0 { " open" } else { "" },
escaped(&title),
self.component(child, context, ancestors.clone())
);
}
output.push_str("</div>");
output
}
fn image(&self, component: &Map<String, Value>, context: &Value) -> String {
let url = self.value(component, "url", context);
let description = self.value(component, "description", context);
let source = self
.images
.get(&url)
.map(String::as_str)
.or_else(|| url.starts_with("data:image/").then_some(url.as_str()));
source.map_or_else(
|| {
format!(
"<div class=\"media\">▧ {}<div class=\"muted\">{}</div></div>",
escaped(&description),
escaped(&url)
)
},
|source| {
format!(
"<img class=\"surface-image\" src=\"{}\" alt=\"{}\"/>",
escaped(source),
escaped(&description)
)
},
)
}
fn media(&self, component: &Map<String, Value>, context: &Value, kind: &str) -> String {
format!(
"<div class=\"media\">{} {}<div class=\"muted\">{}</div></div>",
if kind == "Video" { "" } else { "" },
escaped(&self.value(component, "description", context)),
escaped(&self.value(component, "url", context))
)
}
fn text_field(&self, component: &Map<String, Value>, context: &Value) -> String {
let mut value = self.value(component, "value", context);
if component.get("variant").and_then(Value::as_str) == Some("obscured") {
value = "••••••••".to_owned();
} else if value.is_empty() {
value = self.value(component, "placeholder", context);
}
self.with_error(
component,
self.field(&self.value(component, "label", context), &value),
)
}
fn field(&self, label: &str, value: &str) -> String {
format!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"input\">{}</div></div>",
escaped(label),
escaped(value)
)
}
fn checkbox(&self, component: &Map<String, Value>, context: &Value) -> String {
let checked = bound_value_at(component.get("value"), &self.surface.data, context)
.as_bool()
.unwrap_or(false);
self.with_error(
component,
format!(
"<div class=\"check\"><span class=\"check-mark\">{}</span>{}</div>",
if checked { "" } else { "" },
escaped(&self.value(component, "label", context))
),
)
}
fn slider(&self, component: &Map<String, Value>, context: &Value) -> String {
let value = bound_value_at(component.get("value"), &self.surface.data, context)
.as_f64()
.unwrap_or(0.0);
let min = component.get("min").and_then(Value::as_f64).unwrap_or(0.0);
let max = component
.get("max")
.and_then(Value::as_f64)
.unwrap_or(100.0);
let percent = if max > min {
((value - min) / (max - min) * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
self.with_error(component, format!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
escaped(&self.value(component, "label", context)),
escaped(&display_value(&Value::from(value)))
))
}
fn choices(&self, component: &Map<String, Value>, context: &Value) -> String {
let selected = bound_value_at(component.get("value"), &self.surface.data, context);
let multiple =
component.get("variant").and_then(Value::as_str) == Some("multipleSelection");
let mut output = format!(
"<div class=\"field\"><span class=\"field-label\">{}</span><div class=\"row\">",
escaped(&self.value(component, "label", context))
);
for option in component
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let value = option.get("value").unwrap_or(&Value::Null);
let active = selected
.as_array()
.is_some_and(|values| values.contains(value))
|| (!multiple && &selected == value);
let _ = write!(
output,
"<span class=\"chip\">{} {}</span>",
if active { "" } else { "" },
escaped(&option.get("label").map(display_value).unwrap_or_default())
);
}
output.push_str("</div></div>");
self.with_error(component, output)
}
fn chart(&self, component: &Map<String, Value>, context: &Value) -> String {
let series = bound_value_at(component.get("series"), &self.surface.data, context);
let series = series.as_array().map(Vec::as_slice).unwrap_or(&[]);
let chart_type = component
.get("chartType")
.and_then(Value::as_str)
.unwrap_or("bar");
let mut output = format!(
"<section class=\"chart\"><strong>{}</strong><span class=\"chart-kind\">{}</span>",
escaped(&self.value(component, "title", context)),
escaped(&chart_type.to_uppercase())
);
if matches!(chart_type, "pie" | "donut") {
let total = series
.iter()
.filter_map(|point| point.get("value")?.as_f64())
.filter(|value| *value > 0.0)
.sum::<f64>();
let mut position = 0.0;
let mut stops = Vec::new();
for (index, point) in series.iter().enumerate() {
let value = point
.get("value")
.and_then(Value::as_f64)
.unwrap_or(0.0)
.max(0.0);
let end = if total > 0.0 {
position + value / total * 100.0
} else {
position
};
stops.push(format!(
"{} {position:.3}% {end:.3}%",
COLORS[index % COLORS.len()]
));
position = end;
}
let _ = write!(
output,
"<div class=\"row\"><div class=\"pie {}\" style=\"background:conic-gradient({})\"></div><div class=\"legend\">",
if chart_type == "donut" { "donut" } else { "" },
stops.join(",")
);
for (index, point) in series.iter().enumerate() {
let _ = write!(
output,
"<span><i class=\"swatch\" style=\"background:{}\"></i>{} {}</span>",
COLORS[index % COLORS.len()],
escaped(&point.get("label").map(display_value).unwrap_or_default()),
escaped(&point.get("value").map(display_value).unwrap_or_default())
);
}
output.push_str("</div></div>");
} else if chart_type == "heatmap" {
let columns = series
.iter()
.flat_map(|row| {
row.get("segments")
.and_then(Value::as_array)
.into_iter()
.flatten()
})
.filter_map(|segment| segment.get("label").and_then(Value::as_str))
.fold(Vec::<String>::new(), |mut columns, label| {
if !columns.iter().any(|column| column == label) {
columns.push(label.to_owned());
}
columns
});
let max = series
.iter()
.flat_map(|row| {
row.get("segments")
.and_then(Value::as_array)
.into_iter()
.flatten()
})
.filter_map(|segment| segment.get("value").and_then(Value::as_f64))
.fold(0.0_f64, f64::max)
.max(1.0);
output.push_str("<table class=\"table\"><thead><tr><th></th>");
for column in &columns {
let _ = write!(output, "<th>{}</th>", escaped(column));
}
output.push_str("</tr></thead><tbody>");
for row in series {
let segments = row
.get("segments")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let _ = write!(
output,
"<tr><th>{}</th>",
escaped(&row.get("label").map(display_value).unwrap_or_default())
);
for column in &columns {
let value = segments
.iter()
.find(|segment| {
segment.get("label").and_then(Value::as_str) == Some(column)
})
.and_then(|segment| segment.get("value").and_then(Value::as_f64))
.unwrap_or(0.0);
let alpha = (value / max).clamp(0.0, 1.0);
let _ = write!(
output,
"<td style=\"background:rgba(183,72,72,{alpha:.3})\">{}</td>",
escaped(&display_value(&Value::from(value)))
);
}
output.push_str("</tr>");
}
output.push_str("</tbody></table>");
} else {
let max = series
.iter()
.filter_map(|point| point.get("value")?.as_f64())
.fold(0.0_f64, f64::max)
.max(1.0);
for point in series {
let value = point.get("value").and_then(Value::as_f64).unwrap_or(0.0);
let percent = (value / max * 100.0).clamp(0.0, 100.0);
let _ = write!(
output,
"<div class=\"bar\"><span>{}</span><div class=\"bar-track\"><div class=\"bar-fill\" style=\"width:{percent:.2}%\"></div></div><span>{}</span></div>",
escaped(&point.get("label").map(display_value).unwrap_or_default()),
escaped(&point.get("value").map(display_value).unwrap_or_default())
);
if let Some(segments) = point.get("segments").and_then(Value::as_array) {
let details = segments
.iter()
.map(|segment| {
format!(
"{} {}",
segment.get("label").map(display_value).unwrap_or_default(),
segment.get("value").map(display_value).unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join(" · ");
let _ = write!(output, "<div class=\"muted\">{}</div>", escaped(&details));
}
}
}
output.push_str("</section>");
output
}
fn table(&self, component: &Map<String, Value>, context: &Value) -> String {
let columns = bound_value_at(component.get("columns"), &self.surface.data, context);
let rows = bound_value_at(component.get("rows"), &self.surface.data, context);
let mut output = format!(
"<section><strong>{}</strong><table class=\"table\"><thead><tr>",
escaped(&self.value(component, "title", context))
);
for column in columns.as_array().into_iter().flatten() {
let _ = write!(output, "<th>{}</th>", escaped(&display_value(column)));
}
output.push_str("</tr></thead><tbody>");
for row in rows
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_array)
{
output.push_str("<tr>");
for cell in row {
let _ = write!(output, "<td>{}</td>", escaped(&display_value(cell)));
}
output.push_str("</tr>");
}
output.push_str("</tbody></table></section>");
output
}
fn metric(&self, component: &Map<String, Value>, context: &Value) -> String {
format!(
"<section><div class=\"muted\">{}</div><div class=\"metric-value\">{}</div><div class=\"muted\">{} {}</div></section>",
escaped(&self.value(component, "label", context)),
escaped(&self.value(component, "value", context)),
escaped(&self.value(component, "detail", context)),
escaped(&self.value(component, "trend", context))
)
}
fn timeline(&self, component: &Map<String, Value>, context: &Value) -> String {
let events = bound_value_at(component.get("events"), &self.surface.data, context);
let mut output = format!(
"<section class=\"timeline\"><strong>{}</strong>",
escaped(&self.value(component, "title", context))
);
for event in events.as_array().into_iter().flatten() {
let _ = write!(
output,
"<div class=\"timeline-item\"><span>●</span><div><strong>{}</strong><div class=\"muted\">{} · {} · {}</div></div></div>",
escaped(&event.get("title").map(display_value).unwrap_or_default()),
escaped(&event.get("time").map(display_value).unwrap_or_default()),
escaped(
&event
.get("description")
.map(display_value)
.unwrap_or_default()
),
escaped(&event.get("status").map(display_value).unwrap_or_default())
);
}
output.push_str("</section>");
output
}
fn map(&self, component: &Map<String, Value>, context: &Value) -> String {
let locations = bound_value_at(component.get("locations"), &self.surface.data, context);
let mut output = format!(
"<section class=\"map\"><strong>{}</strong>",
escaped(&self.value(component, "title", context))
);
for location in locations.as_array().into_iter().flatten() {
let _ = write!(
output,
"<div class=\"location\"><span>⌖</span><div><strong>{}</strong><div class=\"muted\">{}, {} · {}</div></div></div>",
escaped(&location.get("label").map(display_value).unwrap_or_default()),
escaped(
&location
.get("latitude")
.map(display_value)
.unwrap_or_default()
),
escaped(
&location
.get("longitude")
.map(display_value)
.unwrap_or_default()
),
escaped(
&location
.get("detail")
.map(display_value)
.unwrap_or_default()
)
);
}
output.push_str("</section>");
output
}
fn mind_map(&self, component: &Map<String, Value>, context: &Value) -> String {
let nodes = bound_value_at(component.get("nodes"), &self.surface.data, context);
let nodes = nodes.as_array().map(Vec::as_slice).unwrap_or(&[]);
let mut output = format!(
"<section class=\"mindmap\"><strong>{}</strong>",
escaped(&self.value(component, "title", context))
);
if let Some(root) = nodes.first() {
output.push_str(&mind_node(nodes, root, 0, BTreeSet::new()));
}
output.push_str("</section>");
output
}
}
fn mind_node(nodes: &[Value], node: &Value, depth: usize, mut seen: BTreeSet<String>) -> String {
let id = node.get("id").map(display_value).unwrap_or_default();
if !seen.insert(id) {
return "<div class=\"muted\">Cycle</div>".to_owned();
}
let mut output = format!(
"<div class=\"mind-node\" style=\"padding-left:{}px\"><span>{}</span><span>{}</span></div>",
depth * 18,
if depth == 0 { "" } else { "" },
escaped(&node.get("label").map(display_value).unwrap_or_default())
);
for child in node
.get("children")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(child) = nodes
.iter()
.find(|candidate| candidate.get("id") == Some(child))
{
output.push_str(&mind_node(nodes, child, depth + 1, seen.clone()));
}
}
output
}
fn layout_style(component: &Map<String, Value>) -> String {
let justify = match component.get("justify").and_then(Value::as_str) {
Some("center") => "center",
Some("end") => "flex-end",
Some("spaceBetween") => "space-between",
Some("spaceAround") => "space-around",
Some("spaceEvenly") => "space-evenly",
_ => "flex-start",
};
let align = match component.get("align").and_then(Value::as_str) {
Some("center") => "center",
Some("end") => "flex-end",
Some("stretch") => "stretch",
_ => "flex-start",
};
format!(" style=\"justify-content:{justify};align-items:{align}\"")
}
fn safe_markdown(markdown: &str) -> String {
let options = Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS;
let parser = Parser::new_ext(markdown, options).scan(Vec::new(), |images, event| {
Some(match event {
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html),
Event::Start(tag @ Tag::Image { .. }) => {
let embedded = matches!(
&tag,
Tag::Image { dest_url, .. } if dest_url.starts_with("data:image/")
);
images.push(embedded);
if embedded {
Event::Start(tag)
} else {
Event::Text("[Image: ".into())
}
}
Event::End(TagEnd::Image) => {
if images.pop().unwrap_or(false) {
Event::End(TagEnd::Image)
} else {
Event::Text("]".into())
}
}
event => event,
})
});
let mut output = String::new();
html::push_html(&mut output, parser);
output
}
fn escaped(text: &str) -> String {
let mut output = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => output.push_str("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'"' => output.push_str("&quot;"),
'\'' => output.push_str("&#39;"),
_ => output.push(character),
}
}
output
}
fn icon(name: &str) -> &'static str {
match name {
"accountCircle" | "person" => "",
"add" => "+",
"arrowBack" => "",
"arrowForward" => "",
"attachFile" => "",
"calendarToday" | "event" => "",
"call" | "phone" => "",
"camera" => "",
"check" => "",
"close" => "×",
"delete" => "",
"download" => "",
"edit" => "",
"error" => "",
"fastForward" => "",
"favorite" => "",
"favoriteOff" => "",
"folder" => "",
"help" => "?",
"home" => "",
"info" => "",
"locationOn" => "",
"lock" => "",
"lockOpen" => "",
"mail" => "",
"menu" => "",
"moreVert" => "",
"moreHoriz" => "",
"notifications" => "",
"notificationsOff" => "",
"pause" => "",
"payment" => "¤",
"photo" => "",
"play" => "",
"print" => "",
"refresh" => "",
"rewind" => "",
"search" => "",
"send" => "",
"settings" => "",
"share" => "",
"shoppingCart" => "",
"skipNext" => "▸|",
"skipPrevious" => "|◂",
"star" => "",
"starHalf" | "starOff" => "",
"stop" => "",
"upload" => "",
"visibility" => "",
"visibilityOff" => "",
"volumeDown" => "",
"volumeMute" => "",
"volumeOff" => "×",
"volumeUp" => "",
"warning" => "",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn static_svg_renderer_covers_the_complete_catalog() {
let entries = [
json!({"id":"text","component":"Text","text":"Hello"}),
json!({"id":"image","component":"Image","url":"https://example.com/a.png","description":"Preview"}),
json!({"id":"icon","component":"Icon","name":"settings"}),
json!({"id":"video","component":"Video","url":"https://example.com/a.mp4"}),
json!({"id":"audio","component":"AudioPlayer","url":"https://example.com/a.mp3"}),
json!({"id":"divider","component":"Divider"}),
json!({"id":"row","component":"Row","children":[]}),
json!({"id":"list","component":"List","children":[]}),
json!({"id":"card","component":"Card","child":"text"}),
json!({"id":"modal","component":"Modal","trigger":"text","content":"text"}),
json!({"id":"tabs","component":"Tabs","tabs":[{"title":"One","child":"text"}]}),
json!({"id":"button","component":"Button","child":"text"}),
json!({"id":"field","component":"TextField","label":"Name","value":"Ada"}),
json!({"id":"check","component":"CheckBox","label":"Ready","value":true}),
json!({"id":"slider","component":"Slider","label":"Amount","value":4,"max":10}),
json!({"id":"date","component":"DateTimeInput","label":"When","value":"2026-08-31"}),
json!({"id":"choice","component":"ChoicePicker","label":"Pick","options":[{"label":"One","value":"one"}],"value":["one"]}),
json!({"id":"chart","component":"Chart","title":"Chart","chartType":"heatmap","series":[{"label":"A","segments":[{"label":"B","value":2}]}]}),
json!({"id":"table","component":"Table","title":"Table","columns":["A"],"rows":[["B"]]}),
json!({"id":"metric","component":"Metric","label":"Count","value":"2"}),
json!({"id":"timeline","component":"Timeline","title":"Timeline","events":[{"time":"Now","title":"Done"}]}),
json!({"id":"map","component":"Map","title":"Map","locations":[{"label":"Here","latitude":1,"longitude":2}]}),
json!({"id":"mind","component":"MindMap","title":"Mind","nodes":[{"id":"n","label":"Root","children":[]}]}),
json!({"id":"form","component":"Form","title":"Form","children":[]}),
];
let ids = entries
.iter()
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
.map(str::to_owned)
.collect::<Vec<_>>();
let mut components = entries
.into_iter()
.map(|entry| (entry["id"].as_str().unwrap().to_owned(), entry))
.collect::<BTreeMap<_, _>>();
components.insert(
"root".into(),
json!({"id":"root","component":"Column","children":ids}),
);
let surface = Surface {
id: "catalog".into(),
catalog_id: super::super::CATALOG_ID.into(),
surface_properties: json!({}),
send_data_model: false,
components,
data: json!({}),
owner_message_id: 1,
};
let svg = surface_svg(&surface, &HashMap::new()).unwrap();
assert!(svg.starts_with("<svg"));
assert!(!svg.contains("Unsupported component"));
assert!(svg.contains("HEATMAP"));
assert!(svg.contains("Submit"));
}
}