Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 82b2102

Browse files
Add MermaidExporter for GitHub/Notion/Obsidian-ready graph diagrams
MermaidExporter renders a JUNG graph as a Mermaid 'flowchart' (or legacy 'graph') block, which renders natively in GitHub READMEs/issues/PRs, Notion, Obsidian, GitLab, and any HTML page with mermaid.js loaded. Joins the existing DOT/GEXF/GraphML/JSON/SVG/TikZ exporter family. Features: TD/TB/BT/LR/RL orientation, optional flowchart-vs-graph keyword, edge-type coloring via classDef+linkStyle (reuses EdgeTypeRegistry palette), optional |label| edge labels, directed/undirected mode, deterministic byte-identical output, path-traversal-safe export, exportToMarkdownBlock() helper for paste-ready READMEs, YAML title frontmatter, custom per-type color overrides, vertex id sanitization for Mermaid grammar, label escaping for quote/pipe/lt/gt/newline. 21 JUnit tests cover null guard, default flowchart TD, legacy 'graph' keyword, directed arrows, orientation validation (LR + invalid + null rejected), edge label rendering and suppression, classDef + linkStyle emission, color-off suppression, special-character label escaping, vertex id sanitization, deterministic output, markdown wrapper, YAML title frontmatter, empty graph, file export, single-edge connector count, custom palette override, and the escapeMermaidLabel helper. All 21 pass.
1 parent 97bdb73 commit 82b2102

2 files changed

Lines changed: 599 additions & 0 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package gvisual;
2+
3+
import edu.uci.ics.jung.graph.Graph;
4+
5+
import java.io.*;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.*;
8+
9+
/**
10+
* Exports a JUNG graph to <a href="https://mermaid.js.org/">Mermaid</a>
11+
* diagram syntax (a {@code graph}/{@code flowchart} block).
12+
*
13+
* <p>Mermaid is the most widely-rendered graph DSL today: it is supported
14+
* natively in GitHub READMEs / issues / PR descriptions, Notion, Obsidian,
15+
* GitLab, Bitbucket, Quarto, and any HTML page that loads
16+
* {@code mermaid.js}. This makes {@code MermaidExporter} the right pick
17+
* for "I want to paste a quick graph into a README" - DOT requires a
18+
* Graphviz tool, GraphML needs a viewer, and the SVG/PNG exporters lose
19+
* editability.</p>
20+
*
21+
* <p>Sibling to the existing {@link DotExporter}, {@link GexfExporter},
22+
* {@link GraphMLExporter}, {@link AdjacencyListExporter},
23+
* {@link JsonGraphExporter}, {@link TikzExporter},
24+
* {@link DimacsExporter}, {@link SvgExporter} and
25+
* {@link InteractiveHtmlExporter}.</p>
26+
*
27+
* <p>Features:</p>
28+
* <ul>
29+
* <li>Configurable orientation: {@code TD}, {@code TB}, {@code BT},
30+
* {@code LR}, {@code RL}.</li>
31+
* <li>Optional {@code flowchart} vs {@code graph} keyword (Mermaid 9+
32+
* prefers {@code flowchart}; older renderers only know
33+
* {@code graph}).</li>
34+
* <li>Edge type &rarr; color mapping via {@code classDef}/{@code class}
35+
* and per-link {@code linkStyle} blocks, reusing the project's
36+
* {@link EdgeTypeRegistry} palette.</li>
37+
* <li>Optional edge labels (uses the {@code A -->|label| B} form).</li>
38+
* <li>Directed (arrow) vs undirected (open line) edges.</li>
39+
* <li>Deterministic ordering (vertices sorted lexicographically,
40+
* edges sorted by {@code (v1,v2)} pair) so the same graph yields
41+
* byte-identical Mermaid output every run.</li>
42+
* <li>Path-traversal-safe file export via
43+
* {@link ExportUtils#validateOutputPath(File)}.</li>
44+
* </ul>
45+
*
46+
* <p>Usage:</p>
47+
* <pre>
48+
* MermaidExporter exporter = new MermaidExporter(graph);
49+
* exporter.setOrientation("LR");
50+
* exporter.setColorByEdgeType(true);
51+
* String md = exporter.exportToString();
52+
*
53+
* // Wrap inside a fenced code block ready for GitHub:
54+
* String mdBlock = exporter.exportToMarkdownBlock();
55+
* </pre>
56+
*
57+
* @author sauravbhattacharya001
58+
*/
59+
public class MermaidExporter {
60+
61+
private static final Set<String> VALID_ORIENTATIONS =
62+
new LinkedHashSet<>(Arrays.asList("TD", "TB", "BT", "LR", "RL"));
63+
64+
private final Graph<String, Edge> graph;
65+
private String orientation = "TD";
66+
private boolean useFlowchartKeyword = true;
67+
private boolean colorByEdgeType = true;
68+
private boolean directed = false;
69+
private boolean showEdgeLabels = true;
70+
private String title; // optional ---\ntitle: ...\n--- frontmatter block
71+
private String description;
72+
private final Map<String, String> typeColors =
73+
new LinkedHashMap<>(EdgeTypeRegistry.getAllHexColors());
74+
75+
/**
76+
* Creates a new Mermaid exporter for the given graph.
77+
*
78+
* @param graph the JUNG graph to export
79+
* @throws IllegalArgumentException if {@code graph} is {@code null}
80+
*/
81+
public MermaidExporter(Graph<String, Edge> graph) {
82+
if (graph == null) {
83+
throw new IllegalArgumentException("Graph must not be null");
84+
}
85+
this.graph = graph;
86+
}
87+
88+
/**
89+
* Sets the diagram orientation. Allowed values: TD, TB, BT, LR, RL.
90+
* Anything else is rejected.
91+
*/
92+
public void setOrientation(String orientation) {
93+
if (orientation == null) {
94+
throw new IllegalArgumentException("Orientation must not be null");
95+
}
96+
String upper = orientation.trim().toUpperCase(Locale.ROOT);
97+
if (!VALID_ORIENTATIONS.contains(upper)) {
98+
throw new IllegalArgumentException(
99+
"Orientation must be one of " + VALID_ORIENTATIONS + ", got: " + orientation);
100+
}
101+
this.orientation = upper;
102+
}
103+
104+
/**
105+
* Toggle between {@code flowchart} (Mermaid 9+, default) and the
106+
* legacy {@code graph} keyword (Mermaid 8 and earlier). The diagrams
107+
* are functionally identical; only the leading keyword differs.
108+
*/
109+
public void setUseFlowchartKeyword(boolean useFlowchart) {
110+
this.useFlowchartKeyword = useFlowchart;
111+
}
112+
113+
/** Whether to color edges by their {@link EdgeType}. Default: true. */
114+
public void setColorByEdgeType(boolean color) { this.colorByEdgeType = color; }
115+
116+
/** Render as a directed graph (arrows). Default: undirected. */
117+
public void setDirected(boolean directed) { this.directed = directed; }
118+
119+
/** Show edge labels (the {@code A -->|label| B} form). Default: true. */
120+
public void setShowEdgeLabels(boolean show) { this.showEdgeLabels = show; }
121+
122+
/** Optional Mermaid title (rendered as YAML frontmatter). */
123+
public void setTitle(String title) { this.title = title; }
124+
125+
/** Optional description comment placed above the diagram body. */
126+
public void setDescription(String description) { this.description = description; }
127+
128+
/** Override the color for a specific edge type. */
129+
public void setTypeColor(String edgeType, String hexColor) {
130+
if (edgeType == null) return;
131+
if (hexColor == null || hexColor.isEmpty()) return;
132+
typeColors.put(edgeType, hexColor);
133+
}
134+
135+
/**
136+
* Exports the graph to a Mermaid file.
137+
*
138+
* @param outputFile the destination file
139+
* @throws IOException if the file cannot be written
140+
* @throws SecurityException if the path escapes allowed directories (CWE-22)
141+
*/
142+
public void export(File outputFile) throws IOException {
143+
ExportUtils.validateOutputPath(outputFile);
144+
try (Writer writer = new OutputStreamWriter(
145+
new FileOutputStream(outputFile), StandardCharsets.UTF_8)) {
146+
writer.write(exportToString());
147+
}
148+
}
149+
150+
/**
151+
* Exports the graph wrapped in a {@code ```mermaid ... ```} fenced
152+
* code block, ready to be pasted into a Markdown document or a
153+
* GitHub README. The fence is added even when {@link #export(File)}
154+
* is also used because the unwrapped form is sometimes preferable
155+
* (Quarto, plain {@code .mmd} files).
156+
*/
157+
public String exportToMarkdownBlock() {
158+
return "```mermaid\n" + exportToString() + "```\n";
159+
}
160+
161+
/**
162+
* Exports the graph to a Mermaid-formatted string.
163+
*/
164+
public String exportToString() {
165+
StringBuilder sb = new StringBuilder();
166+
167+
// YAML frontmatter (optional)
168+
if (title != null && !title.isEmpty()) {
169+
sb.append("---\n");
170+
sb.append("title: ").append(escapeYaml(title)).append("\n");
171+
sb.append("---\n");
172+
}
173+
174+
// Header comment(s) -- Mermaid uses %% for line comments
175+
sb.append("%% Generated by GraphVisual MermaidExporter\n");
176+
if (description != null && !description.isEmpty()) {
177+
sb.append("%% ").append(description.replace('\n', ' ')).append("\n");
178+
}
179+
sb.append("%% Nodes: ").append(graph.getVertexCount())
180+
.append(", Edges: ").append(graph.getEdgeCount())
181+
.append(", Direction: ").append(directed ? "directed" : "undirected")
182+
.append("\n");
183+
184+
// Diagram opening line
185+
String keyword = useFlowchartKeyword ? "flowchart" : "graph";
186+
sb.append(keyword).append(' ').append(orientation).append('\n');
187+
188+
// Stable vertex ordering
189+
List<String> sortedVertices = new ArrayList<>(graph.getVertices());
190+
Collections.sort(sortedVertices);
191+
192+
// Vertex id remapping -- Mermaid ids must match [A-Za-z0-9_].
193+
// We keep the original as the rendered label.
194+
Map<String, String> idMap = new LinkedHashMap<>();
195+
Set<String> usedIds = new HashSet<>();
196+
for (String v : sortedVertices) {
197+
idMap.put(v, sanitizeId(v, usedIds));
198+
}
199+
200+
// Nodes (each with explicit label so spaces/unicode survive)
201+
sb.append(" %% Nodes\n");
202+
for (String v : sortedVertices) {
203+
String id = idMap.get(v);
204+
int deg = graph.degree(v);
205+
sb.append(" ").append(id)
206+
.append("[\"").append(escapeMermaidLabel(v))
207+
.append("<br/><small>deg ").append(deg).append("</small>\"]")
208+
.append('\n');
209+
}
210+
211+
// Edge collection -- deterministic order, dedup for undirected
212+
// multi-edges of the same type.
213+
String connector = directed ? "-->" : "---";
214+
215+
List<EdgeRecord> records = new ArrayList<>();
216+
Set<String> seen = new HashSet<>();
217+
for (Edge e : graph.getEdges()) {
218+
String v1 = e.getVertex1();
219+
String v2 = e.getVertex2();
220+
if (v1 == null || v2 == null) continue;
221+
if (!idMap.containsKey(v1) || !idMap.containsKey(v2)) continue;
222+
String key;
223+
if (directed) {
224+
key = v1 + "\u0001" + v2;
225+
} else {
226+
key = v1.compareTo(v2) <= 0
227+
? v1 + "\u0001" + v2
228+
: v2 + "\u0001" + v1;
229+
}
230+
String type = e.getType();
231+
String typeKey = (type != null) ? key + "\u0002" + type : key;
232+
if (!seen.add(typeKey)) continue;
233+
records.add(new EdgeRecord(v1, v2, e.getType(), e.getLabel(), e.getWeight()));
234+
}
235+
Collections.sort(records);
236+
237+
// Edges
238+
sb.append(" %% Edges\n");
239+
List<String> linkStyleLines = new ArrayList<>();
240+
int edgeIndex = 0;
241+
for (EdgeRecord r : records) {
242+
String id1 = idMap.get(r.v1);
243+
String id2 = idMap.get(r.v2);
244+
sb.append(" ").append(id1).append(' ').append(connector);
245+
if (showEdgeLabels && r.label != null && !r.label.isEmpty()) {
246+
sb.append("|").append(escapeMermaidLabel(r.label)).append("|");
247+
}
248+
sb.append(' ').append(id2).append('\n');
249+
250+
if (colorByEdgeType && r.type != null) {
251+
String hex = typeColors.getOrDefault(r.type, "#CCCCCC");
252+
double penWidth = 1.0 + Math.min(4.0, Math.max(0.0, r.weight) * 0.5);
253+
linkStyleLines.add(String.format(Locale.ROOT,
254+
" linkStyle %d stroke:%s,stroke-width:%.1fpx;",
255+
edgeIndex, hex, penWidth));
256+
}
257+
edgeIndex++;
258+
}
259+
260+
// classDef blocks per edge type that actually appears, so node
261+
// groupings (e.g. members of study groups) can still be styled by
262+
// downstream tooling. We emit one classDef per type even when not
263+
// used directly by nodes -- harmless and convenient.
264+
if (colorByEdgeType) {
265+
sb.append(" %% Edge type palette\n");
266+
for (Map.Entry<String, String> entry : typeColors.entrySet()) {
267+
String code = entry.getKey();
268+
String hex = entry.getValue();
269+
String name = EdgeTypeRegistry.getName(code);
270+
sb.append(" classDef edge_").append(code)
271+
.append(" stroke:").append(hex).append(",stroke-width:2px;")
272+
.append(" %% ").append(escapeMermaidLabel(name)).append('\n');
273+
}
274+
for (String line : linkStyleLines) {
275+
sb.append(line).append('\n');
276+
}
277+
}
278+
279+
return sb.toString();
280+
}
281+
282+
// --- Internal helpers -----------------------------------------------
283+
284+
/** Sanitizes a vertex name into a Mermaid-safe id. */
285+
private static String sanitizeId(String name, Set<String> usedIds) {
286+
StringBuilder out = new StringBuilder();
287+
out.append('n'); // ensure leading char is a letter
288+
for (int i = 0; i < name.length(); i++) {
289+
char c = name.charAt(i);
290+
if (Character.isLetterOrDigit(c) || c == '_') {
291+
out.append(c);
292+
} else {
293+
out.append('_');
294+
}
295+
}
296+
String base = out.toString();
297+
String candidate = base;
298+
int suffix = 1;
299+
while (usedIds.contains(candidate)) {
300+
candidate = base + "_" + suffix++;
301+
}
302+
usedIds.add(candidate);
303+
return candidate;
304+
}
305+
306+
/**
307+
* Escapes a label for Mermaid. Mermaid labels are delimited by
308+
* {@code "} (when wrapped in {@code [" ... "]}) and by {@code |} for
309+
* edge labels. We escape the quote, backslash and pipe characters and
310+
* collapse newlines so we never break the diagram syntax.
311+
*/
312+
static String escapeMermaidLabel(String s) {
313+
if (s == null) return "";
314+
StringBuilder out = new StringBuilder(s.length() + 8);
315+
for (int i = 0; i < s.length(); i++) {
316+
char c = s.charAt(i);
317+
switch (c) {
318+
case '"': out.append("&quot;"); break;
319+
case '|': out.append("&#124;"); break;
320+
case '<': out.append("&lt;"); break;
321+
case '>': out.append("&gt;"); break;
322+
case '\\': out.append("\\\\"); break;
323+
case '\r':
324+
case '\n':
325+
case '\t': out.append(' '); break;
326+
default: out.append(c);
327+
}
328+
}
329+
return out.toString();
330+
}
331+
332+
private static String escapeYaml(String s) {
333+
// Simple, conservative single-line YAML escape.
334+
return s.replace("\n", " ").replace("\"", "\\\"");
335+
}
336+
337+
/** Internal helper -- sortable edge tuple. */
338+
private static final class EdgeRecord implements Comparable<EdgeRecord> {
339+
final String v1, v2;
340+
final String type;
341+
final String label;
342+
final float weight;
343+
EdgeRecord(String v1, String v2, String type, String label, float weight) {
344+
this.v1 = v1; this.v2 = v2;
345+
this.type = type; this.label = label;
346+
this.weight = weight;
347+
}
348+
@Override
349+
public int compareTo(EdgeRecord o) {
350+
int c = v1.compareTo(o.v1);
351+
if (c != 0) return c;
352+
c = v2.compareTo(o.v2);
353+
if (c != 0) return c;
354+
String t1 = type == null ? "" : type;
355+
String t2 = o.type == null ? "" : o.type;
356+
return t1.compareTo(t2);
357+
}
358+
}
359+
}

0 commit comments

Comments
 (0)