use crate::model::Diagram;
const NODE_FILL: &str = "fillColor=#eff6ff;strokeColor=#3b82f6;";
pub fn to_drawio(d: &Diagram) -> String {
let mut cells = String::new();
for r in &d.regions {
let (x, y, w, h) = r.bounds;
cells.push_str(®ion_cell(&r.id, &r.label, x, y, w, h));
}
for c in &d.components {
let (w, h) = c.size.unwrap_or((60, 40));
let (cx, cy) = c.pos;
cells.push_str(&vertex_cell(
&c.id,
&c.name,
node_style(c.shape.as_str()),
cx - w / 2,
cy - h / 2,
w,
h,
));
}
for (i, e) in d.edges.iter().enumerate() {
cells.push_str(&edge_cell(
i, &e.label, &e.src, &e.dst, e.dashed, e.no_arrow,
));
}
envelope(d.width, d.height, &cells)
}
#[cfg(feature = "bpmn")]
pub fn to_drawio_kymojson(json: &str) -> Result<String, String> {
use serde_json::Value;
let root: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
let d = if root.get("diagram").is_some() {
&root["diagram"]
} else {
&root
};
let i = |v: &Value, k: &str| v.get(k).and_then(Value::as_i64).unwrap_or(0) as i32;
let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let nth = |v: &Value, k: usize| {
v.as_array()
.and_then(|a| a.get(k))
.and_then(Value::as_i64)
.unwrap_or(0) as i32
};
let pair = |v: &Value| -> Option<(i32, i32)> {
v.as_array()
.filter(|a| a.len() >= 2)
.map(|_| (nth(v, 0), nth(v, 1)))
};
let empty = Vec::new();
let mut cells = String::new();
for r in d.get("regions").and_then(Value::as_array).unwrap_or(&empty) {
let b = &r["bounds"];
cells.push_str(®ion_cell(
&s(r, "id"),
&s(r, "label"),
nth(b, 0),
nth(b, 1),
nth(b, 2),
nth(b, 3),
));
}
for c in d
.get("components")
.and_then(Value::as_array)
.unwrap_or(&empty)
{
let (w, h) = pair(&c["size"]).unwrap_or((60, 40));
let (cx, cy) = pair(&c["pos"]).unwrap_or((0, 0));
cells.push_str(&vertex_cell(
&s(c, "id"),
&s(c, "name"),
node_style(&s(c, "shape")),
cx - w / 2,
cy - h / 2,
w,
h,
));
}
for (k, e) in d
.get("edges")
.and_then(Value::as_array)
.unwrap_or(&empty)
.iter()
.enumerate()
{
let b = |key: &str| e.get(key).and_then(Value::as_bool).unwrap_or(false);
cells.push_str(&edge_cell(
k,
&s(e, "label"),
&s(e, "src"),
&s(e, "dst"),
b("dashed"),
b("no_arrow"),
));
}
Ok(envelope(i(d, "width"), i(d, "height"), &cells))
}
fn envelope(width: i32, height: i32, cells: &str) -> String {
format!(
"<mxfile host=\"kymostudio\">\n \
<diagram id=\"kymo\" name=\"Flowchart\">\n \
<mxGraphModel dx=\"{width}\" dy=\"{height}\" grid=\"1\" gridSize=\"10\" guides=\"1\" \
tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" \
pageWidth=\"850\" pageHeight=\"1100\" math=\"0\" shadow=\"0\">\n \
<root>\n \
<mxCell id=\"0\"/>\n \
<mxCell id=\"1\" parent=\"0\"/>\n{cells} \
</root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n",
)
}
fn node_style(shape: &str) -> &'static str {
match shape {
"badge" => "rounded=1;whiteSpace=wrap;html=1;arcSize=40;",
"circle" => "ellipse;whiteSpace=wrap;html=1;",
"diamond" => "rhombus;whiteSpace=wrap;html=1;",
"hex" => "shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;",
"cylinder" => "shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;",
_ => "rounded=0;whiteSpace=wrap;html=1;", }
}
fn vertex_cell(id: &str, value: &str, style: &str, x: i32, y: i32, w: i32, h: i32) -> String {
format!(
" <mxCell id=\"{id}\" value=\"{val}\" style=\"{style}{fill}\" vertex=\"1\" \
parent=\"1\">\n \
<mxGeometry x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" as=\"geometry\"/>\n \
</mxCell>\n",
id = xml_attr(id),
val = xml_attr(value),
fill = NODE_FILL,
)
}
fn edge_cell(i: usize, label: &str, src: &str, dst: &str, dashed: bool, no_arrow: bool) -> String {
let mut style = String::from("edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;");
if dashed {
style.push_str("dashed=1;");
}
if no_arrow {
style.push_str("endArrow=none;");
}
format!(
" <mxCell id=\"e{i}\" value=\"{val}\" style=\"{style}\" edge=\"1\" parent=\"1\" \
source=\"{src}\" target=\"{dst}\">\n \
<mxGeometry relative=\"1\" as=\"geometry\"/>\n </mxCell>\n",
val = xml_attr(label),
src = xml_attr(src),
dst = xml_attr(dst),
)
}
fn region_cell(id: &str, label: &str, x: i32, y: i32, w: i32, h: i32) -> String {
format!(
" <mxCell id=\"c_{id}\" value=\"{val}\" style=\"rounded=0;whiteSpace=wrap;html=1;\
fillColor=#f5f9ff;strokeColor=#b8d0ee;verticalAlign=top;align=left;spacingLeft=8;\
spacingTop=4;fontSize=11;fontColor=#475569;\" vertex=\"1\" parent=\"1\">\n \
<mxGeometry x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" as=\"geometry\"/>\n \
</mxCell>\n",
id = xml_attr(id),
val = xml_attr(label),
)
}
fn xml_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::to_drawio;
use crate::model::{Component, Diagram, Edge, Region, Shape};
fn node(id: &str, name: &str, shape: Shape, pos: (i32, i32), size: (i32, i32)) -> Component {
let mut c = Component::flowchart(id, name, shape);
c.pos = pos;
c.size = Some(size);
c
}
#[test]
fn shapes_map_to_mxstyles() {
let cases = [
(Shape::Box, "rounded=0;whiteSpace=wrap;html=1;"),
(Shape::Circle, "ellipse;"),
(Shape::Diamond, "rhombus;"),
(Shape::Hex, "shape=hexagon;"),
(Shape::Cylinder, "shape=cylinder3;"),
(Shape::Badge, "arcSize=40;"),
];
for (shape, needle) in cases {
let mut d = Diagram {
width: 100,
height: 100,
..Default::default()
};
d.components.push(node("A", "L", shape, (50, 50), (60, 40)));
assert!(to_drawio(&d).contains(needle), "{needle}");
}
}
#[test]
fn vertex_geometry_is_top_left_from_centre() {
let mut d = Diagram {
width: 200,
height: 100,
..Default::default()
};
d.components
.push(node("A", "L", Shape::Box, (100, 50), (60, 40)));
assert!(to_drawio(&d).contains("x=\"70\" y=\"30\" width=\"60\" height=\"40\""));
}
#[test]
fn edge_flags_and_label() {
let mut d = Diagram {
width: 100,
height: 100,
..Default::default()
};
let mut e = Edge::routed("A", "B", "go");
e.dashed = true;
d.edges.push(e);
let mut e2 = Edge::routed("A", "B", "");
e2.no_arrow = true;
d.edges.push(e2);
let out = to_drawio(&d);
assert!(out.contains("source=\"A\" target=\"B\""));
assert!(out.contains("value=\"go\"") && out.contains("dashed=1;"));
assert!(out.contains("endArrow=none;"));
}
#[test]
fn region_is_cluster_cell() {
let mut d = Diagram {
width: 100,
height: 100,
..Default::default()
};
let mut r = Region::cluster("G", "Grp", vec!["A".into()]);
r.bounds = (10, 20, 80, 60);
d.regions.push(r);
let out = to_drawio(&d);
assert!(out.contains("id=\"c_G\"") && out.contains("value=\"Grp\""));
assert!(out.contains("x=\"10\" y=\"20\" width=\"80\" height=\"60\""));
}
#[test]
fn non_flowchart_shape_falls_back_and_escapes() {
let mut d = Diagram {
width: 100,
height: 100,
..Default::default()
};
let mut c = Component::flowchart("X", "a & b <c>", Shape::BpmnTask);
c.pos = (40, 30);
d.components.push(c);
let out = to_drawio(&d);
assert!(out.contains("rounded=0;whiteSpace=wrap;html=1;")); assert!(out.contains("value=\"a & b <c>\"")); assert!(out.contains("width=\"60\" height=\"40\"")); }
#[cfg(feature = "bpmn")]
#[test]
fn any_source_path_matches_typed() {
let mut d = Diagram {
width: 200,
height: 100,
..Default::default()
};
d.components
.push(node("A", "Start", Shape::Diamond, (100, 50), (80, 60)));
let mut e = Edge::routed("A", "A", "x");
e.dashed = true;
d.edges.push(e);
let mut r = Region::cluster("G", "Grp", vec!["A".into()]);
r.bounds = (1, 2, 3, 4);
d.regions.push(r);
let envelope = crate::kymojson::export(&d);
assert_eq!(super::to_drawio_kymojson(&envelope).unwrap(), to_drawio(&d));
}
#[test]
fn well_formed_envelope() {
let mut d = Diagram {
width: 100,
height: 100,
..Default::default()
};
d.components
.push(node("A", "L", Shape::Box, (50, 50), (60, 40)));
let out = to_drawio(&d);
assert!(out.starts_with("<mxfile"));
assert!(
out.contains("<mxCell id=\"0\"/>") && out.contains("<mxCell id=\"1\" parent=\"0\"/>")
);
assert!(out.trim_end().ends_with("</mxfile>"));
}
}