use super::*;
use crate::svg::parity::roughjs46::roughjs46_solid_fill_paths_for_closed_polyline_path;
fn arc_points(
x1: f64,
y1: f64,
x2: f64,
y2: f64,
rx: f64,
ry: f64,
clockwise: bool,
) -> Vec<(f64, f64)> {
let num_points: usize = 20;
let mid_x = (x1 + x2) / 2.0;
let mid_y = (y1 + y2) / 2.0;
let angle = (y2 - y1).atan2(x2 - x1);
let dx = (x2 - x1) / 2.0;
let dy = (y2 - y1) / 2.0;
let transformed_x = dx / rx;
let transformed_y = dy / ry;
let distance = (transformed_x * transformed_x + transformed_y * transformed_y).sqrt();
if distance > 1.0 {
return vec![(x1, y1), (x2, y2)];
}
let scaled_center_distance = (1.0 - distance * distance).sqrt();
let sign = if clockwise { -1.0 } else { 1.0 };
let center_x = mid_x + scaled_center_distance * ry * angle.sin() * sign;
let center_y = mid_y - scaled_center_distance * rx * angle.cos() * sign;
let start_angle = ((y1 - center_y) / ry).atan2((x1 - center_x) / rx);
let end_angle = ((y2 - center_y) / ry).atan2((x2 - center_x) / rx);
let mut angle_range = end_angle - start_angle;
if clockwise && angle_range < 0.0 {
angle_range += 2.0 * std::f64::consts::PI;
}
if !clockwise && angle_range > 0.0 {
angle_range -= 2.0 * std::f64::consts::PI;
}
let mut points: Vec<(f64, f64)> = Vec::with_capacity(num_points);
for i in 0..num_points {
let t = i as f64 / (num_points - 1) as f64;
let a = start_angle + t * angle_range;
let x = center_x + rx * a.cos();
let y = center_y + ry * a.sin();
points.push((x, y));
}
points
}
fn rounded_rect_points(w: f64, h: f64) -> Vec<(f64, f64)> {
let radius = 15.0;
let taper = 15.0;
let mut pts: Vec<(f64, f64)> = Vec::new();
pts.push((-w / 2.0 + taper, -h / 2.0));
pts.push((w / 2.0 - taper, -h / 2.0));
pts.extend(arc_points(
w / 2.0 - taper,
-h / 2.0,
w / 2.0,
-h / 2.0 + taper,
radius,
radius,
true,
));
pts.push((w / 2.0, -h / 2.0 + taper));
pts.push((w / 2.0, h / 2.0 - taper));
pts.extend(arc_points(
w / 2.0,
h / 2.0 - taper,
w / 2.0 - taper,
h / 2.0,
radius,
radius,
true,
));
pts.push((w / 2.0 - taper, h / 2.0));
pts.push((-w / 2.0 + taper, h / 2.0));
pts.extend(arc_points(
-w / 2.0 + taper,
h / 2.0,
-w / 2.0,
h / 2.0 - taper,
radius,
radius,
true,
));
pts.push((-w / 2.0, h / 2.0 - taper));
pts.push((-w / 2.0, -h / 2.0 + taper));
pts.extend(arc_points(
-w / 2.0,
-h / 2.0 + taper,
-w / 2.0 + taper,
-h / 2.0,
radius,
radius,
true,
));
pts
}
#[derive(Debug, Clone, Copy)]
enum MindmapPathNumberFormat {
D3Path,
JsNumber,
}
fn mindmap_path_number(v: f64, number_format: MindmapPathNumberFormat) -> String {
match number_format {
MindmapPathNumberFormat::D3Path => fmt_path(v),
MindmapPathNumberFormat::JsNumber => fmt_string(v),
}
}
fn mindmap_cloud_path_d(w: f64, h: f64, number_format: MindmapPathNumberFormat) -> String {
let r1 = 0.15 * w;
let r2 = 0.25 * w;
let r3 = 0.35 * w;
let r4 = 0.2 * w;
let n = |v| mindmap_path_number(v, number_format);
format!(
"M0 0 a{r1},{r1} 0 0,1 {w25},{wn10} a{r3},{r3} 1 0,1 {w40},{wn10} a{r2},{r2} 1 0,1 {w35},{w20} a{r1},{r1} 1 0,1 {w15},{h35} a{r4},{r4} 1 0,1 {wn15},{h65} a{r2},{r1} 1 0,1 {wn25},{w15} a{r3},{r3} 1 0,1 {wn50},0 a{r1},{r1} 1 0,1 {wn25},{wn15} a{r1},{r1} 1 0,1 {wn10},{hn35} a{r4},{r4} 1 0,1 {w10},{hn65} H0 V0 Z",
r1 = n(r1),
r2 = n(r2),
r3 = n(r3),
r4 = n(r4),
w25 = n(w * 0.25),
w40 = n(w * 0.4),
w35 = n(w * 0.35),
w20 = n(w * 0.2),
w15 = n(w * 0.15),
w10 = n(w * 0.1),
wn10 = n(-w * 0.1),
wn15 = n(-w * 0.15),
wn25 = n(-w * 0.25),
wn50 = n(-w * 0.5),
h35 = n(h * 0.35),
h65 = n(h * 0.65),
hn35 = n(-h * 0.35),
hn65 = n(-h * 0.65),
)
}
pub(super) fn mindmap_cloud_rendered_bbox_size_px(w: f64, h: f64) -> Option<(f64, f64)> {
let d = mindmap_cloud_path_d(w, h, MindmapPathNumberFormat::JsNumber);
let pb = svg_path_bounds_from_d(&d)?;
Some((pb.max_x - pb.min_x, pb.max_y - pb.min_y))
}
fn mindmap_bang_path_d(
w_base: f64,
effective_w: f64,
effective_h: f64,
number_format: MindmapPathNumberFormat,
) -> String {
let r = 0.15 * w_base;
let n = |v| mindmap_path_number(v, number_format);
format!(
"M0 0 a{r},{r} 1 0,0 {w25},{hn10} a{r},{r} 1 0,0 {w25},0 a{r},{r} 1 0,0 {w25},0 a{r},{r} 1 0,0 {w25},{h10} a{r},{r} 1 0,0 {w15},{h33} a{r08},{r08} 1 0,0 0,{h34} a{r},{r} 1 0,0 {wn15},{h33} a{r},{r} 1 0,0 {wn25},{h15} a{r},{r} 1 0,0 {wn25},0 a{r},{r} 1 0,0 {wn25},0 a{r},{r} 1 0,0 {wn25},{hn15} a{r},{r} 1 0,0 {wn10},{hn33} a{r08},{r08} 1 0,0 0,{hn34} a{r},{r} 1 0,0 {w10},{hn33} H0 V0 Z",
r = n(r),
r08 = n(r * 0.8),
w25 = n(effective_w * 0.25),
w15 = n(effective_w * 0.15),
w10 = n(effective_w * 0.1),
wn10 = n(-effective_w * 0.1),
wn15 = n(-effective_w * 0.15),
wn25 = n(-effective_w * 0.25),
h10 = n(effective_h * 0.1),
hn10 = n(-effective_h * 0.1),
h15 = n(effective_h * 0.15),
hn15 = n(-effective_h * 0.15),
h33 = n(effective_h * 0.33),
hn33 = n(-effective_h * 0.33),
h34 = n(effective_h * 0.34),
hn34 = n(-effective_h * 0.34),
)
}
fn include_mindmap_rect_bounds(
bounds: &mut Option<Bounds>,
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
) {
if let Some(cur) = bounds.as_mut() {
cur.min_x = cur.min_x.min(min_x);
cur.min_y = cur.min_y.min(min_y);
cur.max_x = cur.max_x.max(max_x);
cur.max_y = cur.max_y.max(max_y);
} else {
*bounds = Some(Bounds {
min_x,
min_y,
max_x,
max_y,
});
}
}
fn include_mindmap_node_rect_bounds(bounds: &mut Option<Bounds>, n: &LayoutNode) {
include_mindmap_rect_bounds(
bounds,
n.x - n.width / 2.0,
n.y - n.height / 2.0,
n.x + n.width / 2.0,
n.y + n.height / 2.0,
);
}
fn include_mindmap_path_bounds(
bounds: &mut Option<Bounds>,
d: &str,
translate_x: f64,
translate_y: f64,
) -> bool {
let Some(pb) = svg_path_bounds_from_d(d) else {
return false;
};
include_mindmap_rect_bounds(
bounds,
pb.min_x + translate_x,
pb.min_y + translate_y,
pb.max_x + translate_x,
pb.max_y + translate_y,
);
true
}
fn mindmap_viewport_bounds_from_layout(
layout: &MindmapDiagramLayout,
model: &merman_core::diagrams::mindmap::MindmapDiagramRenderModel,
) -> Option<Bounds> {
let mut layout_nodes: std::collections::BTreeMap<&str, &LayoutNode> =
std::collections::BTreeMap::new();
for n in &layout.nodes {
layout_nodes.insert(n.id.as_str(), n);
}
let mut bounds: Option<Bounds> = None;
for n in &model.nodes {
let Some(ln) = layout_nodes.get(n.id.as_str()) else {
continue;
};
let padding = n.padding.max(0.0);
let half_padding = padding / 2.0;
match n.shape.as_str() {
"cloud" => {
let bbox_w = ln
.label_width
.unwrap_or_else(|| (ln.width - 2.0 * half_padding).max(1.0));
let bbox_h = ln
.label_height
.unwrap_or_else(|| (ln.height - 2.0 * half_padding).max(1.0));
let w = (bbox_w + 2.0 * half_padding).max(1.0);
let h = (bbox_h + 2.0 * half_padding).max(1.0);
let d = mindmap_cloud_path_d(w, h, MindmapPathNumberFormat::JsNumber);
if !include_mindmap_path_bounds(&mut bounds, &d, ln.x - w / 2.0, ln.y - h / 2.0) {
include_mindmap_node_rect_bounds(&mut bounds, ln);
}
include_mindmap_rect_bounds(
&mut bounds,
ln.x - bbox_w / 2.0,
ln.y - bbox_h / 2.0,
ln.x + bbox_w / 2.0,
ln.y + bbox_h / 2.0,
);
}
"bang" => {
let w = ln.width.max(1.0);
let h = ln.height.max(1.0);
let bbox_w = ln
.label_width
.unwrap_or_else(|| (w - 10.0 * half_padding).max(1.0));
let bbox_h = ln
.label_height
.unwrap_or_else(|| (h - 8.0 * half_padding).max(1.0));
let w_base = bbox_w + 10.0 * half_padding;
let d = mindmap_bang_path_d(w_base, w, h, MindmapPathNumberFormat::JsNumber);
if !include_mindmap_path_bounds(&mut bounds, &d, ln.x - w / 2.0, ln.y - h / 2.0) {
include_mindmap_node_rect_bounds(&mut bounds, ln);
}
include_mindmap_rect_bounds(
&mut bounds,
ln.x - bbox_w / 2.0,
ln.y - bbox_h / 2.0,
ln.x + bbox_w / 2.0,
ln.y + bbox_h / 2.0,
);
}
_ => include_mindmap_node_rect_bounds(&mut bounds, ln),
}
}
for e in &layout.edges {
for p in &e.points {
include_mindmap_rect_bounds(&mut bounds, p.x, p.y, p.x, p.y);
}
}
bounds
}
fn mindmap_css(diagram_id: &str, effective_config: &serde_json::Value) -> String {
let id = escape_xml(diagram_id);
let parts = info_css_parts_with_config(diagram_id, effective_config);
let mut out = parts.css_prefix;
let _ = write!(&mut out, r#"#{} .edge{{stroke-width:3;}}"#, id);
let fills = [
"hsl(240, 100%, 76.2745098039%)",
"hsl(60, 100%, 73.5294117647%)",
"hsl(80, 100%, 76.2745098039%)",
"hsl(270, 100%, 76.2745098039%)",
"hsl(300, 100%, 76.2745098039%)",
"hsl(330, 100%, 76.2745098039%)",
"hsl(0, 100%, 76.2745098039%)",
"hsl(30, 100%, 76.2745098039%)",
"hsl(90, 100%, 76.2745098039%)",
"hsl(150, 100%, 76.2745098039%)",
"hsl(180, 100%, 76.2745098039%)",
"hsl(210, 100%, 76.2745098039%)",
];
let inv_fills = [
"hsl(60, 100%, 86.2745098039%)",
"hsl(240, 100%, 83.5294117647%)",
"hsl(260, 100%, 86.2745098039%)",
"hsl(90, 100%, 86.2745098039%)",
"hsl(120, 100%, 86.2745098039%)",
"hsl(150, 100%, 86.2745098039%)",
"hsl(180, 100%, 86.2745098039%)",
"hsl(210, 100%, 86.2745098039%)",
"hsl(270, 100%, 86.2745098039%)",
"hsl(330, 100%, 86.2745098039%)",
"hsl(0, 100%, 86.2745098039%)",
"hsl(30, 100%, 86.2745098039%)",
];
for (i, (fill, inv)) in fills.iter().zip(inv_fills.iter()).enumerate() {
let section = i as i64 - 1;
let label = if i == 0 || i == 3 { "#ffffff" } else { "black" };
let sw = 17_i64 - 3_i64 * (i as i64);
let _ = write!(
&mut out,
r#"#{} .section-{} rect,#{} .section-{} path,#{} .section-{} circle,#{} .section-{} polygon,#{} .section-{} path{{fill:{};}}"#,
id, section, id, section, id, section, id, section, id, section, fill
);
let _ = write!(
&mut out,
r#"#{} .section-{} text{{fill:{};}}"#,
id, section, label
);
let _ = write!(
&mut out,
r#"#{} .node-icon-{}{{font-size:40px;color:{};}}"#,
id, section, label
);
let _ = write!(
&mut out,
r#"#{} .section-edge-{}{{stroke:{};}}"#,
id, section, fill
);
let _ = write!(
&mut out,
r#"#{} .edge-depth-{}{{stroke-width:{};}}"#,
id, section, sw
);
let _ = write!(
&mut out,
r#"#{} .section-{} line{{stroke:{};stroke-width:3;}}"#,
id, section, inv
);
let _ = write!(
&mut out,
r#"#{} .disabled,#{} .disabled circle,#{} .disabled text{{fill:lightgray;}}#{} .disabled text{{fill:#efefef;}}"#,
id, id, id, id
);
}
let _ = write!(
&mut out,
r#"#{} .section-root rect,#{} .section-root path,#{} .section-root circle,#{} .section-root polygon{{fill:hsl(240, 100%, 46.2745098039%);}}"#,
id, id, id, id
);
let _ = write!(&mut out, r#"#{} .section-root text{{fill:#ffffff;}}"#, id);
let _ = write!(&mut out, r#"#{} .section-root span{{color:#ffffff;}}"#, id);
let _ = write!(&mut out, r#"#{} .section-2 span{{color:#ffffff;}}"#, id);
let _ = write!(
&mut out,
r#"#{} .icon-container{{height:100%;display:flex;justify-content:center;align-items:center;}}"#,
id
);
let _ = write!(&mut out, r#"#{} .edge{{fill:none;}}"#, id);
let _ = write!(
&mut out,
r#"#{} .mindmap-node-label{{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}}"#,
id
);
out.push_str(&parts.root_rule);
out
}
pub(super) fn render_mindmap_diagram_svg(
layout: &MindmapDiagramLayout,
semantic: &serde_json::Value,
_effective_config: &serde_json::Value,
options: &SvgRenderOptions,
) -> Result<String> {
let model: merman_core::diagrams::mindmap::MindmapDiagramRenderModel =
crate::json::from_value_ref(semantic)?;
render_mindmap_diagram_svg_model(layout, &model, _effective_config, options)
}
pub(super) fn render_mindmap_diagram_svg_with_config(
layout: &MindmapDiagramLayout,
semantic: &serde_json::Value,
effective_config: &merman_core::MermaidConfig,
options: &SvgRenderOptions,
) -> Result<String> {
let model: merman_core::diagrams::mindmap::MindmapDiagramRenderModel =
{ crate::json::from_value_ref(semantic)? };
render_mindmap_diagram_svg_model_with_config(layout, &model, effective_config, options)
}
pub(super) fn render_mindmap_diagram_svg_model(
layout: &MindmapDiagramLayout,
model: &merman_core::diagrams::mindmap::MindmapDiagramRenderModel,
_effective_config: &serde_json::Value,
options: &SvgRenderOptions,
) -> Result<String> {
let config = merman_core::MermaidConfig::from_value(_effective_config.clone());
render_mindmap_diagram_svg_model_with_config(layout, model, &config, options)
}
pub(super) fn render_mindmap_diagram_svg_model_with_config(
layout: &MindmapDiagramLayout,
model: &merman_core::diagrams::mindmap::MindmapDiagramRenderModel,
config: &merman_core::MermaidConfig,
options: &SvgRenderOptions,
) -> Result<String> {
let timing_enabled = super::timing::render_timing_enabled();
let mut timings = super::timing::RenderTimings::default();
let total_start = std::time::Instant::now();
fn section<'a>(
enabled: bool,
dst: &'a mut std::time::Duration,
) -> Option<super::timing::TimingGuard<'a>> {
enabled.then(|| super::timing::TimingGuard::new(dst))
}
#[derive(Debug, Clone, serde::Serialize)]
struct Pt {
x: f64,
y: f64,
}
let hand_drawn_seed = config
.as_value()
.get("handDrawnSeed")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let max_node_width_px = crate::mindmap::mindmap_max_node_width_px(config.as_value());
struct MindmapLabelSpec<'a> {
text: &'a str,
label_type: &'a str,
label_bkg: bool,
width: f64,
height: f64,
tx: f64,
ty: f64,
max_node_width_px: f64,
}
fn mk_label(out: &mut String, spec: MindmapLabelSpec<'_>, config: &merman_core::MermaidConfig) {
let MindmapLabelSpec {
text,
label_type,
label_bkg,
width,
height,
tx,
ty,
max_node_width_px,
} = spec;
fn is_simple_markdown(text: &str) -> bool {
if text.contains('\n') || text.contains('\r') {
return false;
}
let trimmed = text.trim_start();
let bytes = trimmed.as_bytes();
if bytes.first().is_some_and(|b| matches!(b, b'#' | b'>')) {
return false;
}
if bytes.starts_with(b"- ") || bytes.starts_with(b"+ ") || bytes.starts_with(b"---") {
return false;
}
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0
&& i + 1 < bytes.len()
&& (bytes[i] == b'.' || bytes[i] == b')')
&& bytes[i + 1] == b' '
{
return false;
}
if text.contains('*')
|| text.contains('_')
|| text.contains('`')
|| text.contains('~')
|| text.contains('[')
|| text.contains(']')
|| text.contains('!')
|| text.contains('\\')
{
return false;
}
if text.contains('<') || text.contains('>') || text.contains('&') {
return false;
}
true
}
fn push_br_normalized_text_into(out: &mut String, text: &str) {
let bytes = text.as_bytes();
let mut i = 0usize;
let mut start = 0usize;
while i + 3 < bytes.len() {
if bytes[i] == b'<' && bytes[i + 1] == b'b' && bytes[i + 2] == b'r' {
if bytes[i + 3] == b'>' {
if start < i {
out.push_str(&text[start..i]);
}
out.push_str("<br />");
i += 4;
start = i;
continue;
}
if i + 4 < bytes.len() && bytes[i + 3] == b'/' && bytes[i + 4] == b'>' {
if start < i {
out.push_str(&text[start..i]);
}
out.push_str("<br />");
i += 5;
start = i;
continue;
}
}
i += 1;
}
if start < text.len() {
out.push_str(&text[start..]);
}
}
let div_class = if label_bkg {
r#" class="labelBkg""#
} else {
""
};
let max_node_width_px = if max_node_width_px.is_finite() && max_node_width_px > 0.0 {
max_node_width_px
} else {
200.0
};
let wrap_container = width >= max_node_width_px - 1e-3;
out.push_str(r#"<g class="label" style="" transform="translate("#);
fmt_into(out, tx);
out.push_str(", ");
fmt_into(out, ty);
out.push_str(r#")"><rect/><foreignObject width=""#);
fmt_into(out, width.max(1.0));
out.push_str(r#"" height=""#);
fmt_into(out, height.max(1.0));
out.push_str(r#""><div xmlns="http://www.w3.org/1999/xhtml""#);
out.push_str(div_class);
out.push_str(r#" style=""#);
if wrap_container {
out.push_str(
"display: table; white-space: break-spaces; line-height: 1.5; max-width: ",
);
fmt_into(out, max_node_width_px);
out.push_str("px; text-align: center; width: ");
fmt_into(out, max_node_width_px);
out.push_str("px;");
} else {
out.push_str("display: table-cell; white-space: nowrap; line-height: 1.5; max-width: ");
fmt_into(out, max_node_width_px);
out.push_str("px; text-align: center;");
}
out.push_str(r#""><span class="nodeLabel">"#);
fn markdown_to_sanitized_xhtml(text: &str, config: &merman_core::MermaidConfig) -> String {
let html_out = crate::text::mermaid_markdown_to_xhtml_label_fragment(text, true);
let html_out = crate::text::replace_fontawesome_icons(&html_out);
let html_out = merman_core::sanitize::sanitize_text(&html_out, config);
html_out
.replace("<br>", "<br />")
.replace("<br/>", "<br />")
.trim()
.to_string()
}
fn is_single_img_fragment(html: &str) -> bool {
let t = html.trim();
let lower = t.to_ascii_lowercase();
if lower.starts_with("<p>") && lower.ends_with("</p>") {
let inner = t.strip_prefix("<p>").unwrap_or(t);
let inner = inner.strip_suffix("</p>").unwrap_or(inner);
return is_single_img_fragment(inner);
}
if !lower.starts_with("<img") {
return false;
}
let Some(end) = t.find('>') else {
return false;
};
t[end + 1..].trim().is_empty()
}
fn unwrap_single_img_p(html: &str) -> String {
let t = html.trim();
if !t.to_ascii_lowercase().starts_with("<p>")
|| !t.to_ascii_lowercase().ends_with("</p>")
{
return t.to_string();
}
let inner = t.strip_prefix("<p>").unwrap_or(t);
let inner = inner.strip_suffix("</p>").unwrap_or(inner);
inner.trim().to_string()
}
fn escape_amp_preserving_entities(raw: &str) -> String {
fn is_valid_entity(entity: &str) -> bool {
if entity.is_empty() {
return false;
}
if let Some(hex) = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"))
{
return !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit());
}
if let Some(dec) = entity.strip_prefix('#') {
return !dec.is_empty() && dec.chars().all(|c| c.is_ascii_digit());
}
let mut it = entity.chars();
let Some(first) = it.next() else {
return false;
};
if !first.is_ascii_alphabetic() {
return false;
}
it.all(|c| c.is_ascii_alphanumeric())
}
let mut out = String::with_capacity(raw.len());
let mut i = 0usize;
while let Some(rel) = raw[i..].find('&') {
let amp = i + rel;
out.push_str(&raw[i..amp]);
let tail = &raw[amp + 1..];
if let Some(semi_rel) = tail.find(';') {
let semi = amp + 1 + semi_rel;
let entity = &raw[amp + 1..semi];
if is_valid_entity(entity) {
out.push_str(&raw[amp..=semi]);
i = semi + 1;
continue;
}
}
out.push_str("&");
i = amp + 1;
}
out.push_str(&raw[i..]);
out
}
if label_type == "markdown" {
if is_simple_markdown(text) {
let mut html_out = String::with_capacity(text.len() + 7);
html_out.push_str("<p>");
html_out.push_str(text);
html_out.push_str("</p>");
let html_out = crate::text::replace_fontawesome_icons(&html_out);
let html_out = decode_mermaid_entities_for_render_text(&html_out);
out.push_str(&escape_amp_preserving_entities(html_out.as_ref()));
} else {
let html = markdown_to_sanitized_xhtml(text, config);
let html = decode_mermaid_entities_for_render_text(&html);
out.push_str(&escape_amp_preserving_entities(html.as_ref()));
}
} else if text.contains('\n') || text.contains('\r') {
if text.contains('`') {
let mut normalized;
let normalized = if text.contains("<br>") || text.contains("<br/>") {
normalized = String::with_capacity(text.len() + 8);
push_br_normalized_text_into(&mut normalized, text);
normalized.as_str()
} else {
text
};
out.push_str("<p>");
out.push_str(&escape_xml(normalized));
out.push_str("</p>");
} else {
out.push_str(&escape_xml(text));
}
} else {
let mut normalized;
let text = if text.contains("<br>") || text.contains("<br/>") {
normalized = String::with_capacity(text.len() + 8);
push_br_normalized_text_into(&mut normalized, text);
normalized.as_str()
} else {
text
};
let trimmed = text.trim();
let is_verbatim =
trimmed.len() >= 2 && trimmed.starts_with('`') && trimmed.ends_with('`');
if is_verbatim {
out.push_str("<p>");
out.push_str(&escape_xml(text));
out.push_str("</p>");
} else if is_simple_markdown(text) {
let mut html_out = String::with_capacity(text.len() + 7);
html_out.push_str("<p>");
html_out.push_str(text);
html_out.push_str("</p>");
let html_out = crate::text::replace_fontawesome_icons(&html_out);
let html_out = decode_mermaid_entities_for_render_text(&html_out);
out.push_str(&escape_amp_preserving_entities(html_out.as_ref()));
} else {
let html = markdown_to_sanitized_xhtml(text, config);
if is_single_img_fragment(&html) {
let html = unwrap_single_img_p(&html);
let html = decode_mermaid_entities_for_render_text(&html);
out.push_str(&escape_amp_preserving_entities(html.as_ref()));
} else {
let html = decode_mermaid_entities_for_render_text(&html);
out.push_str(&escape_amp_preserving_entities(html.as_ref()));
}
}
}
out.push_str("</span></div></foreignObject></g>");
}
fn mk_edge_label(out: &mut String, edge_id: &str) {
let _ = write!(
out,
r#"<g class="edgeLabel"><g class="label" data-id="{id}" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g>"#,
id = escape_xml(edge_id),
);
}
let _g_build_ctx = section(timing_enabled, &mut timings.build_ctx);
let diagram_id = options.diagram_id.as_deref().unwrap_or("mindmap");
let diagram_id_esc = escape_xml(diagram_id);
let mut node_by_id: std::collections::BTreeMap<String, &crate::model::LayoutNode> =
std::collections::BTreeMap::new();
for n in &layout.nodes {
node_by_id.insert(n.id.clone(), n);
}
drop(_g_build_ctx);
let _g_viewbox = section(timing_enabled, &mut timings.viewbox);
let padding = 10.0;
let viewport_bounds =
mindmap_viewport_bounds_from_layout(layout, model).or_else(|| layout.bounds.clone());
let (vx, vy, vw, vh) = viewport_bounds
.as_ref()
.map(|b| {
let w = (b.max_x - b.min_x).max(0.0);
let h = (b.max_y - b.min_y).max(0.0);
(
b.min_x - padding,
b.min_y - padding,
w + 2.0 * padding,
h + 2.0 * padding,
)
})
.unwrap_or((0.0, 0.0, 100.0, 100.0));
let mut view_box_attr = format!("{} {} {} {}", fmt(vx), fmt(vy), fmt(vw), fmt(vh));
let mut max_w_attr = fmt_max_width_px(vw);
let mut w_attr = fmt_string(vw);
let mut h_attr = fmt_string(vh);
apply_root_viewport_override(
diagram_id,
&mut view_box_attr,
&mut w_attr,
&mut h_attr,
&mut max_w_attr,
crate::generated::mindmap_root_overrides_11_12_2::lookup_mindmap_root_viewport_override,
);
drop(_g_viewbox);
let _g_render_svg = section(timing_enabled, &mut timings.render_svg);
let mut out = String::new();
let style_attr = format!("max-width: {max_w_attr}px; background-color: white;");
root_svg::push_svg_root_open(
&mut out,
root_svg::SvgRootAttrs {
class: Some("mindmapDiagram"),
width: root_svg::SvgRootWidth::Percent100,
style_attr: Some(style_attr.as_str()),
viewbox_attr: Some(view_box_attr.as_str()),
trailing_newline: false,
..root_svg::SvgRootAttrs::new(diagram_id, "mindmap")
},
);
let css = mindmap_css(diagram_id, config.as_value());
let _ = write!(&mut out, "<style>{}</style>", css);
out.push_str("<g>");
let _ = write!(
&mut out,
r#"<marker id="{id}_mindmap-pointEnd" class="marker mindmap" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker>"#,
id = diagram_id_esc
);
let _ = write!(
&mut out,
r#"<marker id="{id}_mindmap-pointStart" class="marker mindmap" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker>"#,
id = diagram_id_esc
);
out.push_str(r#"<g class="subgraphs"/>"#);
out.push_str(r#"<g class="edgePaths">"#);
for e in &model.edges {
let (sx, sy, tx, ty) = match (node_by_id.get(&e.start), node_by_id.get(&e.end)) {
(Some(a), Some(b)) => (a.x, a.y, b.x, b.y),
_ => (0.0, 0.0, 0.0, 0.0),
};
let (vx, vy) = (tx - sx, ty - sy);
let v_len = (vx * vx + vy * vy).sqrt();
let (ux, uy) = if v_len == 0.0 {
(0.0, 0.0)
} else {
(vx / v_len, vy / v_len)
};
let endpoint_offset = 15.0;
let start_x = sx + endpoint_offset * ux;
let start_y = sy + endpoint_offset * uy;
let end_x = tx - endpoint_offset * ux;
let end_y = ty - endpoint_offset * uy;
let mid_x = (start_x + end_x) / 2.0;
let mid_y = (start_y + end_y) / 2.0;
let points = [
Pt {
x: start_x,
y: start_y,
},
Pt { x: mid_x, y: mid_y },
Pt { x: end_x, y: end_y },
];
let points_for_data_points = points
.iter()
.map(|p| crate::model::LayoutPoint { x: p.x, y: p.y })
.collect::<Vec<_>>();
let data_points = base64::engine::general_purpose::STANDARD
.encode(json_stringify_points(&points_for_data_points));
let d = if e.curve.trim() == "basis" {
curve::curve_basis_path_d(&points_for_data_points)
} else {
curve::curve_linear_path_d(&points_for_data_points)
};
let class = format!(
"edge-thickness-{} edge-pattern-solid {}",
e.thickness.trim(),
e.classes.trim()
);
let _ = write!(
&mut out,
r#"<path d="{d}" id="{id}" class="{class}" style="undefined;;;undefined" data-edge="true" data-et="edge" data-id="{id}" data-points="{pts}"/>"#,
d = escape_attr(&d),
id = escape_xml(&e.id),
class = escape_xml(&class),
pts = escape_xml(&data_points),
);
}
out.push_str("</g>");
out.push_str(r#"<g class="edgeLabels">"#);
for e in &model.edges {
mk_edge_label(&mut out, &e.id);
}
out.push_str("</g>");
out.push_str(r#"<g class="nodes">"#);
for n in &model.nodes {
let (x, y, w, h, label_w, label_h) = node_by_id
.get(&n.id)
.map(|ln| {
(
ln.x,
ln.y,
ln.width,
ln.height,
ln.label_width,
ln.label_height,
)
})
.unwrap_or((0.0, 0.0, 80.0, 44.0, None, None));
let padding = n.padding.max(0.0);
let half_padding = padding / 2.0;
let class = format!("node {}", n.css_classes.trim());
let _ = write!(
&mut out,
r#"<g class="{class}" id="{dom_id}" transform="translate({x}, {y})">"#,
class = escape_xml(&class),
dom_id = escape_xml(&n.dom_id),
x = fmt(x),
y = fmt(y),
);
match n.shape.as_str() {
"defaultMindmapNode" => {
let rd = 5.0;
let rect_path = format!(
"\n M{} {}\n v{}\n q0,-{} {},-{}\n h{}\n q{},0 {},{}\n v{}\n q0,{} -{},{}\n h{}\n q-{},0 -{},-{}\n Z\n ",
fmt_path(-(w / 2.0)),
fmt_path(h / 2.0 - rd),
fmt_path(-h + 2.0 * rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(w - 2.0 * rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(h - 2.0 * rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(-w + 2.0 * rd),
fmt_path(rd),
fmt_path(rd),
fmt_path(rd),
);
let bbox_w = (w - 8.0 * half_padding).max(1.0);
let bbox_h = (h - 2.0 * half_padding).max(1.0);
let _ = write!(
&mut out,
r#"<path id="node-{id}" class="node-bkg node-0" style="" d="{d}"/>"#,
id = escape_xml(&n.id),
d = escape_attr(&rect_path),
);
let _ = write!(
&mut out,
r#"<line class="node-line-" x1="{x1}" y1="{y}" x2="{x2}" y2="{y}"/>"#,
x1 = fmt(-(w / 2.0)),
x2 = fmt(w / 2.0),
y = fmt(h / 2.0),
);
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
"rect" => {
let bbox_w = (w - 2.0 * padding).max(1.0);
let bbox_h = (h - padding).max(1.0);
let _ = write!(
&mut out,
r#"<rect class="basic label-container" style="" x="{x}" y="{y}" width="{w}" height="{h}"/>"#,
x = fmt(-(w / 2.0)),
y = fmt(-(h / 2.0)),
w = fmt(w.max(1.0)),
h = fmt(h.max(1.0)),
);
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
"rounded" => {
let w = w.max(1.0);
let h = h.max(1.0);
let pts = rounded_rect_points(w, h);
let (fill_d, _stroke_d) = roughjs46_solid_fill_paths_for_closed_polyline_path(
&pts,
hand_drawn_seed,
false,
);
out.push_str(r#"<g class="basic label-container outer-path">"#);
let _ = write!(
&mut out,
r##"<path d="{d}" stroke="none" stroke-width="0" fill="#ECECFF" style=""/>"##,
d = escape_attr(&fill_d),
);
out.push_str("</g>");
let bbox_w = label_w.unwrap_or_else(|| (w - 2.0 * padding).max(1.0));
let bbox_h = label_h.unwrap_or_else(|| (h - 2.0 * padding).max(1.0));
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
"mindmapCircle" => {
let r = (w.max(h) / 2.0).max(1.0);
let _ = write!(
&mut out,
r#"<circle class="basic label-container" style="" r="{r}" cx="0" cy="0"/>"#,
r = fmt(r),
);
let bbox_w = label_w.unwrap_or_else(|| (w - 2.0 * padding).max(1.0));
let bbox_h = label_h.unwrap_or_else(|| (h - 2.0 * padding).max(1.0));
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
"cloud" => {
let bbox_w = label_w.unwrap_or_else(|| (w - 2.0 * half_padding).max(1.0));
let bbox_h = label_h.unwrap_or_else(|| (h - 2.0 * half_padding).max(1.0));
let w = (bbox_w + 2.0 * half_padding).max(1.0);
let h = (bbox_h + 2.0 * half_padding).max(1.0);
let cloud_path = mindmap_cloud_path_d(w, h, MindmapPathNumberFormat::D3Path);
let _ = write!(
&mut out,
r#"<path class="basic label-container" style="" d="{d}" transform="translate({tx}, {ty})"/>"#,
d = escape_attr(&cloud_path),
tx = fmt(-(w / 2.0)),
ty = fmt(-(h / 2.0)),
);
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
"hexagon" => {
let w = w.max(1.0);
let h = h.max(1.0);
let half_width = w / 2.0;
let half_height = h / 2.0;
let fixed_length = half_height / 2.0;
let deduced_width = half_width - fixed_length;
let pts: [(f64, f64); 8] = [
(-deduced_width, -half_height),
(0.0, -half_height),
(deduced_width, -half_height),
(half_width, 0.0),
(deduced_width, half_height),
(0.0, half_height),
(-deduced_width, half_height),
(-half_width, 0.0),
];
let (fill_d, stroke_d) = roughjs46_solid_fill_paths_for_closed_polyline_path(
&pts,
hand_drawn_seed,
true,
);
out.push_str(r#"<g class="basic label-container">"#);
let _ = write!(
&mut out,
r##"<path d="{d}" stroke="none" stroke-width="0" fill="#ECECFF" style=""/>"##,
d = escape_attr(&fill_d),
);
if let Some(stroke_d) = stroke_d {
let _ = write!(
&mut out,
r##"<path d="{d}" stroke="#9370DB" stroke-width="1.3" fill="none" stroke-dasharray="0 0" style=""/>"##,
d = escape_attr(&stroke_d),
);
}
out.push_str("</g>");
let label_width = label_w.unwrap_or_else(|| w.max(1.0));
let label_height = label_h.unwrap_or_else(|| h.max(1.0));
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: label_width,
height: label_height,
tx: -label_width / 2.0,
ty: -label_height / 2.0,
max_node_width_px,
},
config,
);
}
"bang" => {
let bbox_w = label_w.unwrap_or_else(|| (w - 10.0 * half_padding).max(1.0));
let bbox_h = label_h.unwrap_or_else(|| (h - 8.0 * half_padding).max(1.0));
let w_base = bbox_w + 10.0 * half_padding;
let effective_w = w.max(1.0);
let effective_h = h.max(1.0);
let bang_path = mindmap_bang_path_d(
w_base,
effective_w,
effective_h,
MindmapPathNumberFormat::D3Path,
);
let _ = write!(
&mut out,
r#"<path class="basic label-container" style="" d="{d}" transform="translate({tx}, {ty})"/>"#,
d = escape_attr(&bang_path),
tx = fmt(-(effective_w / 2.0)),
ty = fmt(-(effective_h / 2.0)),
);
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: bbox_w,
height: bbox_h,
tx: -bbox_w / 2.0,
ty: -bbox_h / 2.0,
max_node_width_px,
},
config,
);
}
_ => {
let _ = write!(
&mut out,
r#"<rect class="basic label-container" style="" x="{x}" y="{y}" width="{w}" height="{h}"/>"#,
x = fmt(-(w / 2.0)),
y = fmt(-(h / 2.0)),
w = fmt(w.max(1.0)),
h = fmt(h.max(1.0)),
);
mk_label(
&mut out,
MindmapLabelSpec {
text: &n.label,
label_type: &n.label_type,
label_bkg: n.icon.is_some(),
width: w.max(1.0),
height: h.max(1.0),
tx: -w / 2.0,
ty: -h / 2.0,
max_node_width_px,
},
config,
);
}
}
out.push_str("</g>");
}
out.push_str("</g>");
out.push_str("</g></svg>\n");
drop(_g_render_svg);
timings.total = total_start.elapsed();
if timing_enabled {
eprintln!(
"[render-timing] diagram=mindmap total={:?} deserialize={:?} build_ctx={:?} viewbox={:?} render_svg={:?} finalize={:?} nodes={} edges={}",
timings.total,
timings.deserialize_model,
timings.build_ctx,
timings.viewbox,
timings.render_svg,
timings.finalize_svg,
model.nodes.len(),
model.edges.len(),
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewport_bounds_include_cloud_path_bbox() {
let layout = MindmapDiagramLayout {
nodes: vec![LayoutNode {
id: "0".to_string(),
x: 63.953125,
y: 32.0,
width: 97.90625,
height: 34.0,
is_cluster: false,
label_width: Some(87.90625),
label_height: Some(24.0),
}],
edges: Vec::new(),
bounds: Some(Bounds {
min_x: 15.0,
min_y: 15.0,
max_x: 112.90625,
max_y: 49.0,
}),
};
let model = merman_core::diagrams::mindmap::MindmapDiagramRenderModel {
nodes: vec![merman_core::diagrams::mindmap::MindmapDiagramRenderNode {
id: "0".to_string(),
dom_id: "node_0".to_string(),
label: "I am a cloud".to_string(),
label_type: String::new(),
is_group: false,
shape: "cloud".to_string(),
width: 0.0,
height: 0.0,
padding: 10.0,
css_classes: "mindmap-node section-root section--1".to_string(),
css_styles: Vec::new(),
look: String::new(),
icon: None,
x: None,
y: None,
level: 0,
node_id: "id".to_string(),
node_type: -1,
section: Some(-1),
}],
edges: Vec::new(),
};
let bounds = mindmap_viewport_bounds_from_layout(&layout, &model).expect("bounds");
assert!(bounds.min_x < 15.0);
assert!(bounds.min_y < 15.0);
assert!(bounds.max_x > 112.90625);
assert!(bounds.max_y > 49.0);
}
}