use super::constants::*;
use super::parser::{ClassDiagram, ClassNode, ClassRelation, EndType, LineStyle};
use super::templates::{
self as tmpl, build_css, build_markers, drop_shadow_filter, drop_shadow_filter_small,
edge_label_empty, edge_label_fo, edge_label_text, esc, fmt, svg_root, terminal_label_fo,
};
use crate::text::measure;
use crate::theme::{Theme, ThemeVars};
use dagre_dgl_rs::graph::{EdgeLabel, Graph, GraphLabel, NodeLabel, Point};
use dagre_dgl_rs::layout::layout;
pub fn render(diag: &ClassDiagram, theme: Theme, use_foreign_object: bool) -> String {
let vars = theme.resolve();
render_inner(diag, &vars, use_foreign_object)
}
fn render_inner(diag: &ClassDiagram, vars: &ThemeVars, use_foreign_object: bool) -> String {
let mut g = Graph::with_options(false, true, true);
g.set_graph(GraphLabel {
rankdir: Some(diag.direction.clone()),
nodesep: Some(50.0),
ranksep: Some(50.0),
marginx: Some(8.0),
marginy: Some(8.0),
..Default::default()
});
let node_sizes: Vec<(String, f64, f64)> = diag
.class_order
.iter()
.filter_map(|id| {
let cls = diag.classes.get(id)?;
let (w, h) = class_box_size(cls);
Some((id.clone(), w, h))
})
.collect();
for (id, w, h) in &node_sizes {
g.set_node(
id,
NodeLabel {
width: *w,
height: *h,
..Default::default()
},
);
}
for (i, rel) in diag.relations.iter().enumerate() {
let key = Some(format!("e{}", i));
let (lbl_w, lbl_h) = if !rel.title.is_empty() {
let (tw, _) = measure(&rel.title, FONT_SIZE);
(tw * CONTENT_SCALE, 24.0)
} else {
(0.0, 0.0)
};
g.set_edge(
&rel.id1,
&rel.id2,
EdgeLabel {
minlen: Some(1),
weight: Some(1.0),
width: Some(lbl_w),
height: Some(lbl_h),
labelpos: Some("c".to_string()),
..Default::default()
},
key.as_deref(),
);
}
layout(&mut g);
let graph_w_dagre = g.graph().width.unwrap_or(200.0);
let graph_h = g.graph().height.unwrap_or(200.0);
let margin_x = 8.0_f64;
let mut max_terminal_right: f64 = 0.0;
if use_foreign_object {
let terminal_marker_size: f64 = 10.0;
for (i, rel) in diag.relations.iter().enumerate() {
let edge_key = format!("e{}", i);
let e = dagre_dgl_rs::graph::Edge::named(&rel.id1, &rel.id2, &edge_key);
if let Some(lbl_data) = g.edge(&e) {
let pts = lbl_data.points.clone().unwrap_or_default();
if pts.len() >= 2 {
if !rel.title1.is_empty() {
let (cx, _) = calc_terminal_label_position(
terminal_marker_size,
TerminalPos::StartRight,
&pts,
);
let style_w = (rel.title1.len() * 9) as f64;
max_terminal_right = max_terminal_right.max(cx + style_w);
}
if !rel.title2.is_empty() {
let (cx, _) = calc_terminal_label_position(
terminal_marker_size,
TerminalPos::EndLeft,
&pts,
);
let style_w = (rel.title2.len() * 9) as f64;
max_terminal_right = max_terminal_right.max(cx + style_w);
}
}
}
}
}
let graph_w = f64::max(graph_w_dagre, max_terminal_right + margin_x);
let svg_id = "mermaid-svg";
let css = build_css(svg_id, vars);
let mut out = String::new();
out.push_str(&svg_root(svg_id, &fmt(graph_w), &fmt(graph_h)));
out.push_str("<style>");
out.push_str(&css);
out.push_str("</style>");
out.push_str("<g>");
out.push_str(&build_markers(svg_id));
out.push_str("</g>");
out.push_str(r#"<g class="root">"#);
out.push_str(r#"<g class="clusters"></g>"#);
out.push_str(r#"<g class="edgePaths">"#);
for (i, rel) in diag.relations.iter().enumerate() {
let edge_key = format!("e{}", i);
let e = dagre_dgl_rs::graph::Edge::named(&rel.id1, &rel.id2, &edge_key);
if let Some(lbl) = g.edge(&e) {
let pts = lbl.points.clone().unwrap_or_default();
if pts.len() >= 2 {
let edge_id = format!("{}-id_{}_{}_{}", svg_id, rel.id1, rel.id2, i + 1);
let pts = trim_end(
&trim_start(&pts, start_trim(&rel.start)),
end_trim(&rel.end),
);
let path_d = edge_path(&pts);
let is_dashed = rel.line_style == LineStyle::Dashed;
let classes = if is_dashed {
" edge-thickness-normal edge-pattern-dashed relation"
} else {
" edge-thickness-normal edge-pattern-solid relation"
};
let marker_start = marker_start_attr(svg_id, rel);
let marker_end = marker_end_attr(svg_id, rel);
out.push_str(&tmpl::edge_path(
&path_d,
&edge_id,
classes,
&marker_start,
&marker_end,
));
}
}
}
out.push_str("</g>");
out.push_str(r#"<g class="edgeLabels">"#);
for (i, rel) in diag.relations.iter().enumerate() {
let edge_key = format!("e{}", i);
let e = dagre_dgl_rs::graph::Edge::named(&rel.id1, &rel.id2, &edge_key);
if let Some(lbl_data) = g.edge(&e) {
let pts = lbl_data.points.clone().unwrap_or_default();
let edge_id = format!("{}-id_{}_{}_{}", svg_id, rel.id1, rel.id2, i + 1);
if !rel.title.is_empty() {
let mid = midpoint(&pts);
let (raw_fo_w, _) = measure(&rel.title, TITLE_FONT_SIZE);
let fo_w = raw_fo_w * CONTENT_SCALE;
if use_foreign_object {
out.push_str(&edge_label_fo(
&fmt(mid.0),
&fmt(mid.1),
&edge_id,
&fmt(-fo_w / 2.0),
&fmt(fo_w),
&esc(&rel.title),
));
} else {
out.push_str(&edge_label_text(
&fmt(mid.0),
&fmt(mid.1),
&fmt(-fo_w / 2.0),
&fmt(fo_w),
vars.primary_color,
vars.font_family,
&esc(&rel.title),
));
}
} else {
out.push_str(&edge_label_empty(&edge_id));
}
if use_foreign_object {
let terminal_marker_size: f64 = 10.0;
let render_card_label = |text: &str, cx: f64, cy: f64| -> String {
let (fw_raw, _) = measure(text, 11.0);
let fw = fw_raw * TERMINAL_SCALE;
let style_w = text.len() * 9;
terminal_label_fo(&fmt(cx), &fmt(cy), &fmt(fw), style_w, &esc(text))
};
if !rel.title1.is_empty() && pts.len() >= 2 {
let (cx, cy) = calc_terminal_label_position(
terminal_marker_size,
TerminalPos::StartRight,
&pts,
);
out.push_str(&render_card_label(&rel.title1, cx, cy));
}
if !rel.title2.is_empty() && pts.len() >= 2 {
let (cx, cy) = calc_terminal_label_position(
terminal_marker_size,
TerminalPos::EndLeft,
&pts,
);
out.push_str(&render_card_label(&rel.title2, cx + 0.0, cy + 7.0));
}
}
}
}
out.push_str("</g>");
out.push_str(r#"<g class="nodes">"#);
for (class_idx, id) in diag.class_order.iter().enumerate() {
if let Some(cls) = diag.classes.get(id) {
if let Some(n) = g.node_opt(id) {
let cx = n.x.unwrap_or(0.0);
let cy = n.y.unwrap_or(0.0);
let w = n.width;
let h = n.height;
let dom_id = format!("{}-classId-{}-{}", svg_id, id, class_idx);
out.push_str(&render_class_node(
cls,
cx,
cy,
w,
h,
vars,
&dom_id,
use_foreign_object,
));
}
}
}
out.push_str("</g>");
out.push_str("</g>");
out.push_str(&drop_shadow_filter(svg_id));
out.push_str(&drop_shadow_filter_small(svg_id));
out.push_str("</svg>");
out
}
fn section_h_nonzero(rows: usize) -> f64 {
(rows as f64 + 1.0) * MEMBER_ROW_H
}
fn class_box_size(cls: &ClassNode) -> (f64, f64) {
let (raw_name_w, _) = measure(&cls.label, FONT_SIZE);
let name_w = raw_name_w * NAME_SCALE;
let mut max_centred_w: f64 = name_w;
for ann in &cls.annotations {
let (raw_w, _) = measure(&format!("\u{00AB}{}\u{00BB}", ann), FONT_SIZE);
max_centred_w = max_centred_w.max(raw_w * CONTENT_SCALE);
}
let mut max_content_w: f64 = 0.0;
for m in &cls.members {
let (raw_w, _) = measure(&m.display_text(), FONT_SIZE);
max_content_w = max_content_w.max(raw_w * CONTENT_SCALE);
}
for m in &cls.methods {
let (raw_w, _) = measure(&m.display_text(), FONT_SIZE);
max_content_w = max_content_w.max(raw_w * CONTENT_SCALE);
}
let half_centred = max_centred_w / 2.0;
let x_max = f64::max(half_centred, max_content_w);
let bbox_w = x_max + half_centred;
let w = (bbox_w + H_PAD * 2.0).max(MIN_BOX_W);
let ann_rows = cls.annotations.len();
let member_rows = cls.members.len();
let method_rows = cls.methods.len();
let (members_h, methods_h) = match (member_rows, method_rows) {
(0, 0) => (EMPTY_SECTION_H, EMPTY_SECTION_H),
(m, 0) => (section_h_nonzero(m), MEMBER_ROW_H),
(0, me) => (MEMBER_ROW_H, section_h_nonzero(me) + 6.0),
(m, me) => (section_h_nonzero(m), section_h_nonzero(me)),
};
let h = ann_rows as f64 * ANNOTATION_H + HEADER_H + members_h + methods_h;
(w, h)
}
#[allow(clippy::too_many_arguments)]
fn render_class_node(
cls: &ClassNode,
cx: f64,
cy: f64,
w: f64,
h: f64,
vars: &ThemeVars,
dom_id: &str,
use_foreign_object: bool,
) -> String {
let hw = w / 2.0;
let hh = h / 2.0;
let pb = vars.primary_border;
let pf = vars.primary_color;
let mut s = String::new();
s.push_str(&format!(
r#"<g class="node default " id="{did}" data-look="classic" transform="translate({cx}, {cy})">"#,
did = dom_id, cx = fmt(cx), cy = fmt(cy),
));
s.push_str(&format!(
r#"<g class="basic label-container outer-path"><path d="M{x1} {y1} L{x2} {y1} L{x2} {y2} L{x1} {y2}" stroke="none" stroke-width="0" fill="{pf}" style=""></path>"#,
x1 = fmt(-hw), y1 = fmt(-hh), x2 = fmt(hw), y2 = fmt(hh), pf = pf,
));
s.push_str(&format!(
r#"<path d="M{x1} {y1} C{cx1} {y1},{cx2} {y1},{x2} {y1} M{x2} {y1} C{x2} {cy1},{x2} {cy2},{x2} {y2} M{x2} {y2} C{cx3} {y2},{cx4} {y2},{x1} {y2} M{x1} {y2} C{x1} {cy3},{x1} {cy4},{x1} {y1}" stroke="{pb}" stroke-width="1.3" fill="none" stroke-dasharray="0 0" style=""></path></g>"#,
x1 = fmt(-hw), y1 = fmt(-hh), x2 = fmt(hw), y2 = fmt(hh),
cx1 = fmt(-hw * 0.6), cx2 = fmt(hw * 0.4),
cx3 = fmt(hw * 0.5), cx4 = fmt(-hw * 0.2),
cy1 = fmt(-hh * 0.6), cy2 = fmt(hh * 0.5),
cy3 = fmt(hh * 0.5), cy4 = fmt(-hh * 0.1),
pb = pb,
));
let ann_rows = cls.annotations.len();
let member_rows = cls.members.len();
let method_rows = cls.methods.len();
let ann_top_y = -hh;
let div1_y = ann_top_y + ann_rows as f64 * ANNOTATION_H + HEADER_H;
let members_section_h = match (member_rows, method_rows) {
(0, 0) => EMPTY_SECTION_H,
(0, _) => MEMBER_ROW_H, (m, _) => section_h_nonzero(m),
};
let div2_y = div1_y + members_section_h;
let region_h = ann_rows as f64 * ANNOTATION_H + HEADER_H;
let content_h = (ann_rows as f64 + 1.0) * ANNOTATION_H;
let vert_pad = (region_h - content_h) / 2.0;
let ann_group_y = if ann_rows > 0 {
ann_top_y + vert_pad
} else {
ann_top_y + ann_rows as f64 * ANNOTATION_H + HEADER_H / 2.0
};
s.push_str(&format!(
r#"<g class="annotation-group text" transform="translate(0, {})">"#,
fmt(ann_group_y),
));
for (i, ann) in cls.annotations.iter().enumerate() {
let ann_text = format!("«{}»", esc(ann));
let row_centre_rel = i as f64 * ANNOTATION_H + ANNOTATION_H / 2.0;
let (raw_ann_w, _) = measure(&format!("\u{00AB}{}\u{00BB}", ann), FONT_SIZE);
let ann_w = raw_ann_w * CONTENT_SCALE;
if use_foreign_object {
s.push_str(&format!(
r#"<g class="label" style="font-style: italic" transform="translate({ox}, {y})"><foreignObject width="{fw}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel markdown-node-label" style=""><p>{text}</p></span></div></foreignObject></g>"#,
ox = fmt(-ann_w / 2.0),
y = fmt(row_centre_rel - ANNOTATION_H / 2.0),
fw = fmt(ann_w),
text = ann_text,
));
} else {
s.push_str(&format!(
r#"<text x="0" y="{y}" text-anchor="middle" font-family="Arial,sans-serif" font-size="{fs}" fill="{pb}" font-style="italic">{text}</text>"#,
y = fmt(row_centre_rel), fs = FONT_SIZE, pb = pb, text = ann_text,
));
}
}
s.push_str("</g>");
let header_centre_y = ann_top_y + ann_rows as f64 * ANNOTATION_H + HEADER_H / 2.0;
let (raw_name_fo_w, _) = measure(&cls.label, TITLE_FONT_SIZE);
let name_fo_w = raw_name_fo_w * NAME_SCALE;
s.push_str(&format!(
r#"<g class="label-group text" transform="translate({ox}, {gy})">"#,
ox = fmt(-name_fo_w / 2.0),
gy = fmt(header_centre_y),
));
if use_foreign_object {
s.push_str(&format!(
r#"<g class="label" style="font-weight: bolder" transform="translate(0,-12)"><foreignObject width="{fw}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 100px; text-align: center;"><span class="nodeLabel markdown-node-label" style=""><p>{text}</p></span></div></foreignObject></g>"#,
fw = fmt(name_fo_w),
text = esc(&cls.label),
));
} else {
s.push_str(&format!(
r#"<text x="{hw}" y="5" text-anchor="middle" font-family="Arial,sans-serif" font-size="{fs}" fill="{pb}" font-weight="bold">{text}</text>"#,
hw = fmt(name_fo_w / 2.0), fs = TITLE_FONT_SIZE, pb = pb,
text = esc(&cls.label),
));
}
s.push_str("</g>");
let members_group_y = div1_y + MEMBER_ROW_H;
s.push_str(&format!(
r#"<g class="members-group text" transform="translate({ox}, {gy})">"#,
ox = fmt(-hw + H_PAD),
gy = fmt(members_group_y),
));
for (i, m) in cls.members.iter().enumerate() {
let text = m.display_text();
let (raw_mem_w, _) = measure(&text, FONT_SIZE);
let mem_fo_w = raw_mem_w * CONTENT_SCALE;
let row_y = i as f64 * MEMBER_ROW_H;
if use_foreign_object {
s.push_str(&format!(
r#"<g class="label" style="" transform="translate(0,{y})"><foreignObject width="{fw}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 150px; text-align: center;"><span class="nodeLabel markdown-node-label" style=""><p>{text}</p></span></div></foreignObject></g>"#,
y = fmt(row_y - 12.0),
fw = fmt(mem_fo_w),
text = esc(&text),
));
} else {
s.push_str(&format!(
r#"<text x="0" y="{y}" font-family="Arial,sans-serif" font-size="{fs}" fill="{pb}">{text}</text>"#,
y = fmt(row_y), fs = FONT_SIZE, pb = pb, text = esc(&text),
));
}
}
s.push_str("</g>");
let methods_group_y = div2_y + MEMBER_ROW_H;
s.push_str(&format!(
r#"<g class="methods-group text" transform="translate({ox}, {gy})">"#,
ox = fmt(-hw + H_PAD),
gy = fmt(methods_group_y),
));
for (i, m) in cls.methods.iter().enumerate() {
let text = m.display_text();
let (raw_meth_w, _) = measure(&text, FONT_SIZE);
let meth_fo_w = raw_meth_w * CONTENT_SCALE;
let row_y = i as f64 * MEMBER_ROW_H;
if use_foreign_object {
s.push_str(&format!(
r#"<g class="label" style="" transform="translate(0,{y})"><foreignObject width="{fw}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel markdown-node-label" style=""><p>{text}</p></span></div></foreignObject></g>"#,
y = fmt(row_y - 12.0),
fw = fmt(meth_fo_w),
text = esc(&text),
));
} else {
s.push_str(&format!(
r#"<text x="0" y="{y}" font-family="Arial,sans-serif" font-size="{fs}" fill="{pb}">{text}</text>"#,
y = fmt(row_y), fs = FONT_SIZE, pb = pb, text = esc(&text),
));
}
}
s.push_str("</g>");
s.push_str(&format!(
r#"<g class="divider" style=""><path d="M{x1} {y} C{cx1} {y},{cx2} {y},{x2} {y}" stroke="{pb}" stroke-width="1.3" fill="none" stroke-dasharray="0 0" style=""></path></g>"#,
x1 = fmt(-hw), y = fmt(div1_y),
cx1 = fmt(-hw * 0.4), cx2 = fmt(hw * 0.4),
x2 = fmt(hw), pb = pb,
));
s.push_str(&format!(
r#"<g class="divider" style=""><path d="M{x1} {y} C{cx1} {y},{cx2} {y},{x2} {y}" stroke="{pb}" stroke-width="1.3" fill="none" stroke-dasharray="0 0" style=""></path></g>"#,
x1 = fmt(-hw), y = fmt(div2_y),
cx1 = fmt(-hw * 0.4), cx2 = fmt(hw * 0.4),
x2 = fmt(hw), pb = pb,
));
s.push_str("</g>"); s
}
fn marker_start_attr(svg_id: &str, rel: &ClassRelation) -> String {
match &rel.start {
EndType::None => String::new(),
EndType::Extension => format!(r#" marker-start="url(#{}_class-extensionStart)""#, svg_id),
EndType::Composition => {
format!(r#" marker-start="url(#{}_class-compositionStart)""#, svg_id)
}
EndType::Aggregation => {
format!(r#" marker-start="url(#{}_class-aggregationStart)""#, svg_id)
}
EndType::Arrow => format!(r#" marker-start="url(#{}_class-dependencyStart)""#, svg_id),
}
}
fn marker_end_attr(svg_id: &str, rel: &ClassRelation) -> String {
match &rel.end {
EndType::None => String::new(),
EndType::Extension => format!(r#" marker-end="url(#{}_class-extensionEnd)""#, svg_id),
EndType::Composition => format!(r#" marker-end="url(#{}_class-compositionEnd)""#, svg_id),
EndType::Aggregation => format!(r#" marker-end="url(#{}_class-aggregationEnd)""#, svg_id),
EndType::Arrow => format!(r#" marker-end="url(#{}_class-dependencyEnd)""#, svg_id),
}
}
fn end_trim(end: &EndType) -> f64 {
match end {
EndType::Extension | EndType::Composition | EndType::Aggregation => 17.0,
EndType::Arrow => 8.0,
EndType::None => 0.0,
}
}
fn start_trim(start: &EndType) -> f64 {
match start {
EndType::Extension | EndType::Composition | EndType::Aggregation => 17.0,
EndType::Arrow => 8.0,
EndType::None => 0.0,
}
}
fn trim_end(pts: &[Point], amount: f64) -> Vec<Point> {
if amount <= 0.0 || pts.len() < 2 {
return pts.to_vec();
}
let mut result = pts.to_vec();
let n = result.len();
let last = result[n - 1].clone();
let prev = result[n - 2].clone();
let dx = last.x - prev.x;
let dy = last.y - prev.y;
let len = (dx * dx + dy * dy).sqrt();
if len <= amount {
result.truncate(n - 1);
} else {
let frac = (len - amount) / len;
result[n - 1] = Point {
x: prev.x + dx * frac,
y: prev.y + dy * frac,
};
}
result
}
fn trim_start(pts: &[Point], amount: f64) -> Vec<Point> {
if amount <= 0.0 || pts.len() < 2 {
return pts.to_vec();
}
let mut result = pts.to_vec();
let first = result[0].clone();
let next = result[1].clone();
let dx = next.x - first.x;
let dy = next.y - first.y;
let len = (dx * dx + dy * dy).sqrt();
if len <= amount {
result.remove(0);
} else {
let frac = amount / len;
result[0] = Point {
x: first.x + dx * frac,
y: first.y + dy * frac,
};
}
result
}
fn edge_path(pts: &[Point]) -> String {
let pairs: Vec<(f64, f64)> = pts.iter().map(|p| (p.x, p.y)).collect();
crate::svg::curve_basis_path(&pairs)
}
fn midpoint(pts: &[Point]) -> (f64, f64) {
if pts.is_empty() {
return (0.0, 0.0);
}
let mid = pts.len() / 2;
(pts[mid].x, pts[mid].y)
}
#[derive(Clone, Copy)]
enum TerminalPos {
StartRight,
EndLeft,
}
fn calc_terminal_label_position(
terminal_marker_size: f64,
position: TerminalPos,
points: &[Point],
) -> (f64, f64) {
let fwd: Vec<(f64, f64)> = points.iter().map(|p| (p.x, p.y)).collect();
let rev: Vec<(f64, f64)> = fwd.iter().cloned().rev().collect();
let pts_owned: Vec<(f64, f64)> = match position {
TerminalPos::StartRight => fwd,
TerminalPos::EndLeft => rev,
};
let pts_ref: &[(f64, f64)] = &pts_owned;
let distance_to_cardinality_point = 25.0 + terminal_marker_size;
let center = {
let mut prev: Option<(f64, f64)> = None;
let mut remaining = distance_to_cardinality_point;
let mut result = pts_ref[pts_ref.len() - 1];
for &p in pts_ref {
if let Some(prev_p) = prev {
let dx = p.0 - prev_p.0;
let dy = p.1 - prev_p.1;
let seg_len = (dx * dx + dy * dy).sqrt();
if seg_len == 0.0 {
prev = Some(p);
continue;
}
if seg_len < remaining {
remaining -= seg_len;
} else {
let ratio = remaining / seg_len;
result = (
(1.0 - ratio) * prev_p.0 + ratio * p.0,
(1.0 - ratio) * prev_p.1 + ratio * p.1,
);
break;
}
}
prev = Some(p);
}
result
};
let d = 10.0 + terminal_marker_size * 0.5;
let p0 = pts_ref[0];
let angle = f64::atan2(p0.1 - center.1, p0.0 - center.0);
let (x, y) = match position {
TerminalPos::StartRight => {
let x = angle.sin() * d + (p0.0 + center.0) / 2.0;
let y = -angle.cos() * d + (p0.1 + center.1) / 2.0;
(x, y)
}
TerminalPos::EndLeft => {
let x = angle.sin() * d + (p0.0 + center.0) / 2.0 - 5.0;
let y = -angle.cos() * d + (p0.1 + center.1) / 2.0 - 5.0;
(x, y)
}
};
(x, y)
}
#[cfg(test)]
mod tests {
use super::super::parser;
use super::*;
const CLASS_BASIC: &str = "classDiagram\n class Animal {\n +String name\n +int age\n +makeSound() void\n }\n class Dog {\n +String breed\n +fetch() void\n }\n Animal <|-- Dog";
#[test]
fn basic_render_produces_svg() {
let diag = parser::parse(CLASS_BASIC).diagram;
let svg = render(&diag, Theme::Default, false);
assert!(svg.contains("<svg"), "missing <svg tag");
assert!(svg.contains("Animal"), "missing class name");
assert!(svg.contains("Dog"), "missing class name");
}
#[test]
fn dark_theme() {
let diag = parser::parse(CLASS_BASIC).diagram;
let svg = render(&diag, Theme::Dark, false);
assert!(svg.contains("<svg"), "missing <svg tag");
}
#[test]
fn snapshot_default_theme() {
let diag = parser::parse(CLASS_BASIC).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}