Files
yacy_search_server/source/net/yacy/ai/tools/Mermaid2ASCIITool.java
Michael Peter Christen ccf42a19c0 added Mermaid rendering tool
2026-02-08 13:34:43 +01:00

4136 lines
167 KiB
Java
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BeautifulMermaid
* Copyright 2026 by Michael Peter Christen
* MIT License
*
* Original Go project (beautiful-mermaid is based on mermaid-ascii)
* https://github.com/AlexanderGrooff/mermaid-ascii
* Copyright (c) 2024 Alexander Grooff (@AlexanderGrooff), MIT License
*
* Original TypeScript Project Author of
* https://github.com/lukilabs/beautiful-mermaid:
* Copyright (c) 2026 Luki Labs (@balintorosz), MIT License
*
* This Java Version was written by Codex, transcoded from the python version from
* https://github.com/Orbiter/beautiful-mermaid-py
* Copyright (c) 2026 Michael Christen (@orbiterlab), MIT License
*
* Supports:
* - Flowcharts / stateDiagram-v2 (grid + A* pathfinding)
* - sequenceDiagram
* - classDiagram
* - erDiagram
*/
package net.yacy.ai.tools;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import net.yacy.ai.ToolHandler;
public final class Mermaid2ASCIITool implements ToolHandler {
private static final String NAME = "mermaid_to_ascii";
private static final Pattern FLOW_HEADER = Pattern.compile("^(?:graph|flowchart)\\s+(TD|TB|LR|BT|RL)\\s*$", Pattern.CASE_INSENSITIVE);
private static final Pattern STATE_HEADER = Pattern.compile("^stateDiagram(?:-v2)?\\s*$", Pattern.CASE_INSENSITIVE);
private static final Pattern SEQ_HEADER = Pattern.compile("^sequenceDiagram\\s*$", Pattern.CASE_INSENSITIVE);
private static final Pattern CLASS_HEADER = Pattern.compile("^classDiagram\\s*$", Pattern.CASE_INSENSITIVE);
private static final Pattern ER_HEADER = Pattern.compile("^erDiagram\\s*$", Pattern.CASE_INSENSITIVE);
private static final Pattern SUBGRAPH_START = Pattern.compile("^subgraph\\s+([^\\[]+?)(?:\\s*\\[(.+)])?\\s*$");
private static final Pattern STATE_BLOCK_START = Pattern.compile("^state\\s+([A-Za-z0-9_\\-*]+)\\s*\\{\\s*$");
private static final Pattern NODE_DEF = Pattern.compile("^([A-Za-z0-9_\\-*]+)(.*)$");
private static final Pattern BARE_NODE = Pattern.compile("^([A-Za-z0-9_\\-*]+)");
private static final Pattern CLASS_SHORTHAND = Pattern.compile("^:::([A-Za-z][A-Za-z0-9_-]*)");
private static final Pattern PARTICIPANT = Pattern.compile("^(participant|actor)\\s+([A-Za-z0-9_\\-*]+)(?:\\s+as\\s+(.+))?$", Pattern.CASE_INSENSITIVE);
private static final Pattern SEQ_NOTE = Pattern.compile("^Note\\s+(left of|right of|over)\\s+([^:]+)\\s*:\\s*(.+)$", Pattern.CASE_INSENSITIVE);
private static final Pattern CLASS_DEF = Pattern.compile("^class\\s+([A-Za-z0-9_\\-]+)(?:\\s*\\{)?\\s*$");
private static final Pattern ER_ENTITY = Pattern.compile("^([A-Za-z0-9_\\-]+)\\s*\\{\\s*$");
private static final String[] EDGE_OPS = {"<-->", "<-.->", "<==>", "-->", "-.->", "==>", "---", "-.-", "===", "--", "->>", "-->>", "-)", "--)"};
private static final Pattern[] FLOW_NODE_PATTERNS = new Pattern[] {
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\(\\(\\((.+?)\\)\\)\\)"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\(\\[(.+?)\\]\\)"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\(\\((.+?)\\)\\)"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\[\\[(.+?)\\]\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\[\\((.+?)\\)\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\[/(.+?)\\\\\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\[\\\\(.+?)/\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)>(.+?)\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\{\\{(.+?)\\}\\}"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\[(.+?)\\]"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\((.+?)\\)"),
Pattern.compile("^([A-Za-z0-9_\\-*]+)\\{(.+?)\\}")
};
@Override
public JSONObject definition() throws JSONException {
JSONObject tool = new JSONObject(true);
tool.put("type", "function");
JSONObject fn = new JSONObject(true);
fn.put("name", NAME);
fn.put("description", "Render Mermaid diagram text as ASCII/Unicode box-art text.");
JSONObject params = new JSONObject(true);
params.put("type", "object");
JSONObject props = new JSONObject(true);
JSONObject mermaidCode = new JSONObject(true);
mermaidCode.put("type", "string");
mermaidCode.put("description", "Mermaid diagram source code.");
props.put("mermaid_code", mermaidCode);
JSONObject useAscii = new JSONObject(true);
useAscii.put("type", "boolean");
useAscii.put("description", "Use pure ASCII output instead of Unicode box drawing characters.");
props.put("use_ascii", useAscii);
JSONObject paddingX = new JSONObject(true);
paddingX.put("type", "integer");
paddingX.put("description", "Horizontal padding between nodes.");
props.put("padding_x", paddingX);
JSONObject paddingY = new JSONObject(true);
paddingY.put("type", "integer");
paddingY.put("description", "Vertical padding between nodes.");
props.put("padding_y", paddingY);
JSONObject boxPadding = new JSONObject(true);
boxPadding.put("type", "integer");
boxPadding.put("description", "Inner padding inside rendered node boxes.");
props.put("box_padding", boxPadding);
params.put("properties", props);
params.put("required", new JSONArray().put("mermaid_code"));
fn.put("parameters", params);
tool.put("function", fn);
return tool;
}
@Override
public int maxCallsPerTurn() {
return 1;
}
@Override
public String execute(String arguments) {
final JSONObject args;
try {
args = (arguments == null || arguments.isEmpty()) ? new JSONObject(true) : new JSONObject(arguments);
} catch (JSONException e) {
return ToolHandler.errorJson("Invalid arguments JSON");
}
final String mermaidCode = args.optString("mermaid_code", "").trim();
if (mermaidCode.isEmpty()) return ToolHandler.errorJson("Missing mermaid_code");
final boolean useAscii = args.has("use_ascii") && args.optBoolean("use_ascii", false);
final int paddingX = args.optInt("padding_x", 3);
final int paddingY = args.optInt("padding_y", 2);
final int boxPadding = args.optInt("box_padding", 1);
try {
String ascii = renderMermaidAscii(mermaidCode, useAscii, paddingX, paddingY, boxPadding);
JSONObject result = new JSONObject(true);
result.put("diagram_type", detectDiagramType(mermaidCode));
result.put("use_ascii", useAscii);
result.put("ascii_art", ascii);
return result.toString();
} catch (Exception e) {
return ToolHandler.errorJson("Failed to render Mermaid diagram: " + e.getMessage());
}
}
public static void main(String[] args) {
try {
CliOptions options = CliOptions.parse(args);
if (options == null) {
printUsage();
System.exit(2);
return;
}
String text = Files.readString(options.input, StandardCharsets.UTF_8);
String output = renderMermaidAscii(text, options.useAscii, options.paddingX, options.paddingY, options.boxPadding);
System.out.print(output);
if (!output.endsWith("\n")) {
System.out.println();
}
} catch (Exception e) {
System.err.println(e.getMessage());
System.exit(1);
}
}
public static String renderMermaidAscii(String text, boolean useAscii, int paddingX, int paddingY, int boxPadding) {
String type = detectDiagramType(text);
if ("sequence".equals(type)) {
return renderSequence(text, useAscii);
}
if ("class".equals(type)) {
return renderClassDiagram(text, useAscii);
}
if ("er".equals(type)) {
return renderErDiagram(text, useAscii);
}
return renderFlowOrState(text, useAscii, Math.max(3, paddingX), Math.max(2, paddingY), Math.max(1, boxPadding));
}
private static String detectDiagramType(String text) {
List<String> lines = splitLines(text);
if (lines.isEmpty()) {
return "flow";
}
String first = lines.get(0).trim();
if (SEQ_HEADER.matcher(first).matches()) return "sequence";
if (CLASS_HEADER.matcher(first).matches()) return "class";
if (ER_HEADER.matcher(first).matches()) return "er";
return "flow";
}
private static List<String> splitLines(String text) {
if (text == null || text.isEmpty()) return Collections.emptyList();
String[] parts = text.replace("\r\n", "\n").replace('\r', '\n').split("\\n");
List<String> out = new ArrayList<>();
for (String p : parts) {
String t = p.trim();
if (!t.isEmpty() && !t.startsWith("%%")) out.add(t);
}
return out;
}
private static String renderFlowOrState(String text, boolean useAscii, int paddingX, int paddingY, int boxPad) {
List<String> linesRaw = splitLines(text);
if (linesRaw.isEmpty()) return "";
List<String> lines = new ArrayList<>();
for (String line : linesRaw) {
for (String seg : line.split(";")) {
String s = seg.trim();
if (!s.isEmpty() && !s.startsWith("%%")) lines.add(s);
}
}
if (lines.isEmpty()) return "";
String direction = "TD";
String header = lines.get(0);
Matcher fm = FLOW_HEADER.matcher(header);
boolean isState = false;
if (fm.matches()) {
direction = fm.group(1).toUpperCase();
} else if (STATE_HEADER.matcher(header).matches()) {
direction = "TD";
isState = true;
}
FlowGraph g = new FlowGraph(direction);
if (isState) parseStateDiagram(lines, g);
else parseFlowLike(lines, g);
if (g.nodes.isEmpty()) return "";
return renderFlowParity(g, useAscii, paddingX, paddingY, boxPad);
}
private static void parseStateDiagram(List<String> lines, FlowGraph g) {
ArrayDeque<Subgraph> stack = new ArrayDeque<>();
int startCount = 0;
int endCount = 0;
Pattern dirRe = Pattern.compile("^direction\\s+(TD|TB|LR|BT|RL)\\s*$", Pattern.CASE_INSENSITIVE);
Pattern compStart = Pattern.compile("^state\\s+(?:\"([^\"]+)\"\\s+as\\s+)?(\\w+)\\s*\\{$");
Pattern aliasRe = Pattern.compile("^state\\s+\"([^\"]+)\"\\s+as\\s+(\\w+)\\s*$");
Pattern transRe = Pattern.compile("^(\\[\\*\\]|[\\w-]+)\\s*(-->)\\s*(\\[\\*\\]|[\\w-]+)(?:\\s*:\\s*(.+))?$");
Pattern descRe = Pattern.compile("^([\\w-]+)\\s*:\\s*(.+)$");
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i);
Matcher dm = dirRe.matcher(line);
if (dm.matches()) {
String dir = dm.group(1).toUpperCase();
if (!stack.isEmpty()) stack.peek().direction = dir;
else g.direction = dir;
continue;
}
Matcher cm = compStart.matcher(line);
if (cm.matches()) {
String label = cm.group(1) != null ? cm.group(1) : cm.group(2);
String id = cm.group(2);
Subgraph sg = new Subgraph(id, label, stack.peek(), null);
if (stack.peek() != null) stack.peek().children.add(sg);
g.subgraphs.add(sg);
stack.push(sg);
continue;
}
if ("}".equals(line)) {
if (!stack.isEmpty()) stack.pop();
continue;
}
Matcher am = aliasRe.matcher(line);
if (am.matches()) {
ensureNode(g, am.group(2), am.group(1), stack.peek());
continue;
}
Matcher tm = transRe.matcher(line);
if (tm.matches()) {
String src = tm.group(1);
String tgt = tm.group(3);
String label = tm.group(4) == null ? "" : tm.group(4).trim();
if ("[*]".equals(src)) {
startCount++;
src = "_start" + (startCount > 1 ? startCount : "");
ensureNode(g, src, "", stack.peek());
} else {
ensureNode(g, src, src, stack.peek());
}
if ("[*]".equals(tgt)) {
endCount++;
tgt = "_end" + (endCount > 1 ? endCount : "");
ensureNode(g, tgt, "", stack.peek());
} else {
ensureNode(g, tgt, tgt, stack.peek());
}
g.edges.add(new FlowEdge(src, tgt, label, "-->"));
continue;
}
Matcher dsm = descRe.matcher(line);
if (dsm.matches()) {
ensureNode(g, dsm.group(1), dsm.group(2).trim(), stack.peek());
}
}
}
private static void parseFlowLike(List<String> lines, FlowGraph g) {
ArrayDeque<Subgraph> stack = new ArrayDeque<>();
boolean inStateBlock = false;
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i).trim();
if (line.isEmpty()) continue;
if (line.toLowerCase().startsWith("subgraph ")) {
String rest = line.substring("subgraph ".length()).trim();
String id;
String label;
Matcher bracket = Pattern.compile("^([A-Za-z0-9_\\-]+)\\s*\\[(.+)]$").matcher(rest);
if (bracket.matches()) {
id = bracket.group(1);
label = bracket.group(2);
} else {
label = rest;
id = rest.replaceAll("\\s+", "_").replaceAll("[^A-Za-z0-9_\\-]", "");
if (id.isEmpty()) id = label;
}
Subgraph sg = new Subgraph(id, label, stack.peek(), null);
if (stack.peek() != null) stack.peek().children.add(sg);
g.subgraphs.add(sg);
stack.push(sg);
continue;
}
Matcher ss = STATE_BLOCK_START.matcher(line);
if (ss.matches()) {
inStateBlock = true;
ensureNode(g, ss.group(1), ss.group(1), stack.peek());
continue;
}
if ("}".equals(line)) {
inStateBlock = false;
continue;
}
if ("end".equalsIgnoreCase(line)) {
if (!stack.isEmpty()) stack.pop();
continue;
}
Matcher dirMatch = Pattern.compile("^direction\\s+(TD|TB|LR|BT|RL)\\s*$", Pattern.CASE_INSENSITIVE).matcher(line);
if (dirMatch.matches()) {
String dir = dirMatch.group(1).toUpperCase();
if (!stack.isEmpty()) stack.peek().direction = dir;
else g.direction = dir;
continue;
}
if (line.toLowerCase().startsWith("classdef ") || line.toLowerCase().startsWith("class ") || line.toLowerCase().startsWith("style ")) {
continue;
}
if (line.contains("-->" ) || line.contains("-.->") || line.contains("==>") || line.contains("---") || line.contains("-.-") || line.contains("===") || line.contains("<-->") || line.contains("<-.->") || line.contains("<==>")) {
parseFlowEdgeLine(line, g, stack.peek());
} else {
parseNodeOnlyLine(line, g, stack.peek(), inStateBlock);
}
}
}
private static void parseNodeOnlyLine(String line, FlowGraph g, Subgraph current, boolean inStateBlock) {
String s = line;
if (s.startsWith("[*]")) {
ensureNode(g, "START", "", current);
return;
}
NodeConsume nc = consumeNode(s, g, current);
if (nc != null) return;
Matcher m = NODE_DEF.matcher(s);
if (!m.matches()) return;
String id = m.group(1);
String label = extractLabel(id, m.group(2));
ensureNode(g, id, label, current);
}
private static void parseFlowEdgeLine(String line, FlowGraph g, Subgraph current) {
String work = line;
int pos = findFirstOp(work);
if (pos < 0) {
parseNodeOnlyLine(line, g, current, false);
return;
}
String left = work.substring(0, pos).trim();
String rest = work.substring(pos).trim();
List<String> prevGroupIds = parseNodeGroup(left, g, current);
if (prevGroupIds.isEmpty()) return;
while (true) {
EdgeToken tok = readEdgeToken(rest);
if (tok == null) break;
List<String> nextGroupIds = parseNodeGroup(tok.targetToken, g, current);
if (nextGroupIds.isEmpty()) break;
for (String src : prevGroupIds) {
for (String tgt : nextGroupIds) {
g.edges.add(new FlowEdge(src, tgt, tok.label, tok.op));
}
}
prevGroupIds = nextGroupIds;
rest = tok.remaining.trim();
if (findFirstOp(rest) < 0) break;
}
}
private static List<String> parseNodeGroup(String token, FlowGraph g, Subgraph current) {
List<String> ids = new ArrayList<>();
String rem = token == null ? "" : token.trim();
NodeConsume first = consumeNode(rem, g, current);
if (first == null || first.id == null || first.id.isEmpty()) return ids;
ids.add(first.id);
rem = first.remaining.trim();
while (rem.startsWith("&")) {
rem = ltrim(rem.substring(1));
NodeConsume nxt = consumeNode(rem, g, current);
if (nxt == null || nxt.id == null || nxt.id.isEmpty()) break;
ids.add(nxt.id);
rem = nxt.remaining.trim();
}
return ids;
}
private static String parseSingleNodeToken(String token, FlowGraph g, Subgraph current) {
String t = token.trim();
if (t.isEmpty()) return "";
if ("[*]".equals(t)) {
ensureNode(g, "START", "", current);
return "START";
}
String id = extractNodeId(t);
String label = extractLabel(id, t.substring(Math.min(id.length(), t.length())));
ensureNode(g, id, label, current);
return id;
}
private static String parseNodeToken(String token, FlowGraph g, Subgraph current) {
String t = token.trim();
if (t.isEmpty()) return "";
if ("[*]".equals(t)) {
ensureNode(g, "START", "", current);
return "START";
}
String[] parts = t.split("&");
String firstId = "";
for (int i = 0; i < parts.length; i++) {
String part = parts[i].trim();
if (part.isEmpty()) continue;
String id = extractNodeId(part);
String label = extractLabel(id, part.substring(Math.min(id.length(), part.length())));
ensureNode(g, id, label, current);
if (firstId.isEmpty()) firstId = id;
}
return firstId;
}
private static NodeConsume consumeNode(String text, FlowGraph g, Subgraph current) {
String t = ltrim(text == null ? "" : text);
if (t.isEmpty()) return null;
if (t.startsWith("[*]")) {
ensureNode(g, "START", "", current);
String rem = t.substring(3);
Matcher cm = CLASS_SHORTHAND.matcher(rem);
if (cm.find()) rem = rem.substring(cm.end());
return new NodeConsume("START", rem);
}
for (Pattern p : FLOW_NODE_PATTERNS) {
Matcher m = p.matcher(t);
if (m.find()) {
String id = m.group(1);
String label = m.group(2);
ensureNode(g, id, label, current);
String rem = t.substring(m.end());
Matcher cm = CLASS_SHORTHAND.matcher(ltrim(rem));
if (cm.find()) rem = ltrim(rem).substring(cm.end());
return new NodeConsume(id, rem);
}
}
Matcher bare = BARE_NODE.matcher(t);
if (bare.find()) {
String id = bare.group(1);
if (!g.nodes.containsKey(id)) ensureNode(g, id, id, current);
else if (current != null) {
current.nodeIds.add(id);
g.nodes.get(id).subgraphs.add(current);
}
String rem = t.substring(bare.end());
Matcher cm = CLASS_SHORTHAND.matcher(ltrim(rem));
if (cm.find()) rem = ltrim(rem).substring(cm.end());
return new NodeConsume(id, rem);
}
return null;
}
private static String ltrim(String s) {
return s == null ? "" : s.replaceFirst("^\\s+", "");
}
private static int findFirstOp(String s) {
int best = Integer.MAX_VALUE;
for (String op : EDGE_OPS) {
int idx = s.indexOf(op);
if (idx >= 0 && idx < best) best = idx;
}
return best == Integer.MAX_VALUE ? -1 : best;
}
private static EdgeToken readEdgeToken(String s) {
String ws = s.trim();
int opPos = findFirstOp(ws);
if (opPos != 0) return null;
String opFound = null;
for (String op : EDGE_OPS) {
if (ws.startsWith(op)) {
if (opFound == null || op.length() > opFound.length()) opFound = op;
}
}
if (opFound == null) return null;
String remain = ws.substring(opFound.length());
String label = "";
if (remain.startsWith("|")) {
int end = remain.indexOf('|', 1);
if (end > 0) {
label = remain.substring(1, end).trim();
remain = remain.substring(end + 1);
}
}
int next = findFirstOp(remain);
String target;
String rest;
if (next < 0) {
target = remain.trim();
rest = "";
} else {
target = remain.substring(0, next).trim();
rest = remain.substring(next);
}
return new EdgeToken(opFound, label, target, rest);
}
private static String extractNodeId(String s) {
String t = s.trim();
if (t.startsWith("[*]")) return "START";
int i = 0;
while (i < t.length()) {
char c = t.charAt(i);
if (Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '*') {
i++;
} else {
break;
}
}
if (i == 0) return t;
return t.substring(0, i);
}
private static String extractLabel(String id, String tail) {
String t = tail == null ? "" : tail.trim();
if (t.isEmpty()) return normalizeStateLabel(id);
String v = t;
v = v.replaceFirst("(:::([A-Za-z][A-Za-z0-9_-]*))+\\s*$", "").trim();
if (v.startsWith(":::")) return normalizeStateLabel(id);
if (v.contains(":")) {
int c = v.indexOf(':');
if (c >= 0 && c + 1 < v.length()) return v.substring(c + 1).trim();
}
String[] wrappers = {
"(((", ")))",
"([", "])",
"[/", "\\]",
"[\\", "/]",
"((", "))",
"[(", ")]",
"[[", "]]",
"{{", "}}",
"[", "]",
"(", ")",
"{", "}"
};
for (int i = 0; i + 1 < wrappers.length; i += 2) {
String a = wrappers[i];
String b = wrappers[i + 1];
if (v.startsWith(a) && v.endsWith(b) && v.length() > a.length() + b.length()) {
return v.substring(a.length(), v.length() - b.length()).trim();
}
}
if (v.startsWith(">") && v.endsWith("]") && v.length() > 2) {
return v.substring(1, v.length() - 1).trim();
}
return normalizeStateLabel(id);
}
private static String normalizeStateLabel(String id) {
if ("START".equals(id)) return "";
if ("[*]".equals(id)) return "";
return id;
}
private static void ensureNode(FlowGraph g, String id, String label, Subgraph current) {
FlowNode n = g.nodes.get(id);
if (n == null) {
n = new FlowNode(id, label == null ? id : label);
g.nodes.put(id, n);
} else if (!n.isPseudo() && (n.label == null || n.label.isBlank() || n.label.equals(n.id))) {
if (label != null && !label.isBlank()) n.label = label;
}
boolean pseudoMarker = id.startsWith("_start") || id.startsWith("_end") || "START".equals(id) || "END".equals(id);
if (current != null && !pseudoMarker) {
current.nodeIds.add(id);
n.subgraphs.add(current);
}
}
private static void layoutFlow(FlowGraph g, int paddingX, int paddingY) {
Map<String, Integer> indeg = new HashMap<>();
Map<String, List<String>> out = new HashMap<>();
for (String id : g.nodes.keySet()) indeg.put(id, 0);
for (FlowEdge e : g.edges) {
out.computeIfAbsent(e.from, k -> new ArrayList<>()).add(e.to);
indeg.put(e.to, indeg.getOrDefault(e.to, 0) + 1);
}
Queue<String> q = new ArrayDeque<>();
for (String id : g.nodes.keySet()) {
if (indeg.getOrDefault(id, 0) == 0) q.add(id);
}
if (q.isEmpty() && !g.nodes.isEmpty()) q.add(g.nodes.keySet().iterator().next());
Map<String, Integer> level = new HashMap<>();
for (String id : g.nodes.keySet()) level.put(id, 0);
Set<String> processed = new HashSet<>();
Set<String> enqueued = new HashSet<>(q);
while (!q.isEmpty()) {
String cur = q.poll();
if (!processed.add(cur)) {
continue;
}
int lv = level.getOrDefault(cur, 0);
for (String nxt : out.getOrDefault(cur, Collections.emptyList())) {
if (level.getOrDefault(nxt, 0) < lv + 1) level.put(nxt, lv + 1);
indeg.put(nxt, indeg.getOrDefault(nxt, 0) - 1);
if (indeg.get(nxt) <= 0 && !processed.contains(nxt) && enqueued.add(nxt)) q.add(nxt);
}
}
int maxLv = 0;
for (int lv : level.values()) maxLv = Math.max(maxLv, lv);
List<List<FlowNode>> layers = new ArrayList<>();
for (int i = 0; i <= maxLv; i++) layers.add(new ArrayList<>());
for (FlowNode n : g.nodes.values()) {
int lv = level.getOrDefault(n.id, 0);
if (n.isPseudo()) {
n.w = 3;
n.h = 3;
} else {
n.w = Math.max(7, n.label.length() + 4);
n.h = 5;
}
layers.get(lv).add(n);
}
for (List<FlowNode> l : layers) {
l.sort(Comparator.comparing(a -> a.id));
}
boolean horizontal = "LR".equals(g.direction) || "RL".equals(g.direction);
int layerGap = horizontal ? paddingX + 12 : paddingY + 6;
int rowGap = horizontal ? paddingY + 6 : paddingX + 12;
int primary = 2;
for (int lv = 0; lv < layers.size(); lv++) {
List<FlowNode> layer = layers.get(lv);
int secondary = 2;
for (FlowNode n : layer) {
if (horizontal) {
n.x = primary;
n.y = secondary;
secondary += n.h + rowGap;
} else {
n.x = secondary;
n.y = primary;
secondary += n.w + rowGap;
}
}
if (horizontal) {
int maxW = layer.stream().mapToInt(a -> a.w).max().orElse(8);
primary += maxW + layerGap;
} else {
int maxH = layer.stream().mapToInt(a -> a.h).max().orElse(5);
primary += maxH + layerGap;
}
}
}
private static void drawSubgraphs(Canvas c, FlowGraph g, boolean useAscii) {
for (Subgraph sg : g.subgraphs) {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
for (String id : sg.nodeIds) {
FlowNode n = g.nodes.get(id);
if (n == null) continue;
minX = Math.min(minX, n.x - 2);
minY = Math.min(minY, n.y - 2);
maxX = Math.max(maxX, n.x + n.w + 2);
maxY = Math.max(maxY, n.y + n.h + 2);
}
if (minX == Integer.MAX_VALUE) continue;
c.drawRect(minX, minY, maxX - minX, maxY - minY, useAscii);
c.putText(Math.max(minX + 1, 0), Math.max(minY + 1, 0), sg.label);
}
}
private static void drawBox(Canvas c, int x, int y, int w, int h, String label, boolean useAscii) {
c.drawRect(x, y, w, h, useAscii);
if (label == null || label.isBlank()) return;
int tx = x + Math.max(1, (w - label.length()) / 2);
int ty = y + h / 2;
c.putText(tx, ty, label);
}
private static void routeAndDrawEdges(Canvas c, FlowGraph g, String direction, boolean useAscii) {
Set<Long> edgeCells = new HashSet<>();
Set<Long> blocked = new HashSet<>();
int minX = 0, minY = 0, maxX = c.w - 1, maxY = c.rows.size() - 1;
for (FlowNode n : g.nodes.values()) {
for (int x = n.x; x <= n.x + n.w; x++) {
for (int y = n.y; y <= n.y + n.h; y++) {
blocked.add(pack(x, y));
}
}
}
for (FlowEdge e : g.edges) {
FlowNode a = g.nodes.get(e.from);
FlowNode b = g.nodes.get(e.to);
if (a == null || b == null) continue;
Point s = anchorFrom(a, b);
Point t = anchorTo(a, b);
blocked.remove(pack(s.x, s.y));
blocked.remove(pack(t.x, t.y));
List<Point> path = findPath(s, t, blocked, edgeCells, minX, minY, maxX, maxY);
if (path == null || path.size() < 2) {
path = new ArrayList<>();
path.add(s);
path.add(new Point((s.x + t.x) / 2, s.y));
path.add(new Point((s.x + t.x) / 2, t.y));
path.add(t);
}
drawPath(c, path, e, useAscii);
for (Point p : path) edgeCells.add(pack(p.x, p.y));
}
}
private static Point anchorFrom(FlowNode a, FlowNode b) {
int acx = a.x + a.w / 2, acy = a.y + a.h / 2;
int bcx = b.x + b.w / 2, bcy = b.y + b.h / 2;
if (Math.abs(acx - bcx) >= Math.abs(acy - bcy)) {
if (acx <= bcx) return new Point(a.x + a.w + 1, acy);
return new Point(a.x - 1, acy);
}
if (acy <= bcy) return new Point(acx, a.y + a.h + 1);
return new Point(acx, a.y - 1);
}
private static Point anchorTo(FlowNode a, FlowNode b) {
int acx = a.x + a.w / 2, acy = a.y + a.h / 2;
int bcx = b.x + b.w / 2, bcy = b.y + b.h / 2;
if (Math.abs(acx - bcx) >= Math.abs(acy - bcy)) {
if (acx <= bcx) return new Point(b.x - 1, bcy);
return new Point(b.x + b.w + 1, bcy);
}
if (acy <= bcy) return new Point(bcx, b.y - 1);
return new Point(bcx, b.y + b.h + 1);
}
private static List<Point> findPath(Point s, Point t, Set<Long> blocked, Set<Long> used, int minX, int minY, int maxX, int maxY) {
Map<Long, Integer> gScore = new HashMap<>();
Map<Long, Long> parent = new HashMap<>();
ArrayDeque<Long> openQ = new ArrayDeque<>();
Set<Long> inOpen = new HashSet<>();
Set<Long> closed = new HashSet<>();
long start = pack(s.x, s.y), goal = pack(t.x, t.y);
gScore.put(start, 0);
openQ.add(start);
inOpen.add(start);
while (!openQ.isEmpty()) {
long cur = -1;
int bestF = Integer.MAX_VALUE;
for (long id : openQ) {
Point p = unpack(id);
int gcur = gScore.getOrDefault(id, Integer.MAX_VALUE / 4);
int f = gcur + Math.abs(p.x - t.x) + Math.abs(p.y - t.y);
if (f < bestF) {
bestF = f;
cur = id;
}
}
openQ.remove(cur);
inOpen.remove(cur);
if (cur == goal) {
List<Point> out = new ArrayList<>();
long at = cur;
while (true) {
out.add(unpack(at));
if (at == start) break;
at = parent.get(at);
}
Collections.reverse(out);
return simplifyPath(out);
}
closed.add(cur);
Point p = unpack(cur);
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
for (int[] d : dirs) {
int nx = p.x + d[0], ny = p.y + d[1];
if (nx < minX || ny < minY || nx > maxX || ny > maxY) continue;
long nid = pack(nx, ny);
if (blocked.contains(nid) && nid != goal) continue;
if (closed.contains(nid)) continue;
int step = 1 + (used.contains(nid) ? 30 : 0);
int tentative = gScore.get(cur) + step;
if (tentative < gScore.getOrDefault(nid, Integer.MAX_VALUE / 4)) {
parent.put(nid, cur);
gScore.put(nid, tentative);
if (!inOpen.contains(nid)) {
openQ.add(nid);
inOpen.add(nid);
}
}
}
}
return null;
}
private static List<Point> simplifyPath(List<Point> path) {
if (path.size() <= 2) return path;
List<Point> out = new ArrayList<>();
out.add(path.get(0));
for (int i = 1; i < path.size() - 1; i++) {
Point a = path.get(i - 1), b = path.get(i), c = path.get(i + 1);
int dx1 = Integer.compare(b.x - a.x, 0), dy1 = Integer.compare(b.y - a.y, 0);
int dx2 = Integer.compare(c.x - b.x, 0), dy2 = Integer.compare(c.y - b.y, 0);
if (dx1 == dx2 && dy1 == dy2) continue;
out.add(b);
}
out.add(path.get(path.size() - 1));
return out;
}
private static void drawPath(Canvas c, List<Point> path, FlowEdge e, boolean useAscii) {
char hChar = (e.op.contains("-.->") || e.op.contains("-.-") || e.op.contains("<-.->")) ? (useAscii ? '.' : '╌') : (useAscii ? '-' : '─');
char vChar = (e.op.contains("-.->") || e.op.contains("-.-") || e.op.contains("<-.->")) ? (useAscii ? ':' : '┊') : (useAscii ? '|' : '│');
for (int i = 0; i < path.size() - 1; i++) {
Point a = path.get(i), b = path.get(i + 1);
int x = a.x, y = a.y;
int dx = Integer.compare(b.x - a.x, 0), dy = Integer.compare(b.y - a.y, 0);
while (x != b.x || y != b.y) {
int nx = x + dx, ny = y + dy;
if (dx != 0) {
mergeLine(c, x, y, dx > 0 ? DIR_E : DIR_W, hChar, useAscii);
mergeLine(c, nx, ny, dx > 0 ? DIR_W : DIR_E, hChar, useAscii);
} else {
mergeLine(c, x, y, dy > 0 ? DIR_S : DIR_N, vChar, useAscii);
mergeLine(c, nx, ny, dy > 0 ? DIR_N : DIR_S, vChar, useAscii);
}
x = nx; y = ny;
}
}
if (!e.label.isBlank()) {
int bestI = -1;
int bestLen = -1;
for (int i = 0; i < path.size() - 1; i++) {
Point a = path.get(i), b = path.get(i + 1);
int len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
if (len > bestLen) {
bestLen = len;
bestI = i;
}
}
if (bestI >= 0) {
Point a = path.get(bestI), b = path.get(bestI + 1);
if (a.y == b.y) {
int x1 = Math.min(a.x, b.x), x2 = Math.max(a.x, b.x);
int tx = Math.max(0, (x1 + x2) / 2 - e.label.length() / 2);
int ty = Math.max(0, a.y - 1);
c.putText(tx, ty, e.label);
} else {
int y1 = Math.min(a.y, b.y), y2 = Math.max(a.y, b.y);
int tx = Math.max(0, a.x + 1);
int ty = Math.max(0, (y1 + y2) / 2);
c.putText(tx, ty, e.label);
}
}
}
if (e.op.contains(">")) {
Point a = path.get(path.size() - 2), b = path.get(path.size() - 1);
char ah;
if (b.x > a.x) ah = useAscii ? '>' : '►';
else if (b.x < a.x) ah = useAscii ? '<' : '◄';
else if (b.y > a.y) ah = useAscii ? 'v' : '▼';
else ah = useAscii ? '^' : '▲';
c.put(b.x, b.y, ah);
}
}
private static final int DIR_N = 1;
private static final int DIR_E = 2;
private static final int DIR_S = 4;
private static final int DIR_W = 8;
private static void mergeLine(Canvas c, int x, int y, int addDir, char fallback, boolean ascii) {
char cur = c.get(x, y);
int mask = charMask(cur, ascii);
mask |= addDir;
c.put(x, y, charFromMask(mask, ascii, fallback));
}
private static int charMask(char ch, boolean ascii) {
if (ascii) {
if (ch == '-') return DIR_E | DIR_W;
if (ch == '|') return DIR_N | DIR_S;
if (ch == '+') return DIR_N | DIR_E | DIR_S | DIR_W;
return 0;
}
switch (ch) {
case '─':
return DIR_E | DIR_W;
case '│':
return DIR_N | DIR_S;
case '┌':
return DIR_E | DIR_S;
case '┐':
return DIR_W | DIR_S;
case '└':
return DIR_E | DIR_N;
case '┘':
return DIR_W | DIR_N;
case '├':
return DIR_N | DIR_E | DIR_S;
case '┤':
return DIR_N | DIR_W | DIR_S;
case '┬':
return DIR_E | DIR_W | DIR_S;
case '┴':
return DIR_E | DIR_W | DIR_N;
case '┼':
return DIR_N | DIR_E | DIR_S | DIR_W;
case '╌':
return DIR_E | DIR_W;
case '┊':
return DIR_N | DIR_S;
default:
return 0;
}
}
private static char charFromMask(int m, boolean ascii, char fallback) {
if (ascii) {
boolean h = (m & (DIR_E | DIR_W)) != 0;
boolean v = (m & (DIR_N | DIR_S)) != 0;
if (h && v) return '+';
if (h) return '-';
if (v) return '|';
return fallback;
}
switch (m) {
case DIR_E | DIR_W:
return '─';
case DIR_N | DIR_S:
return '│';
case DIR_E | DIR_S:
return '┌';
case DIR_W | DIR_S:
return '┐';
case DIR_E | DIR_N:
return '└';
case DIR_W | DIR_N:
return '┘';
case DIR_N | DIR_E | DIR_S:
return '├';
case DIR_N | DIR_W | DIR_S:
return '┤';
case DIR_E | DIR_W | DIR_S:
return '┬';
case DIR_E | DIR_W | DIR_N:
return '┴';
case DIR_N | DIR_E | DIR_S | DIR_W:
return '┼';
default:
return fallback;
}
}
private static long pack(int x, int y) {
return (((long) x) << 32) ^ (y & 0xffffffffL);
}
private static Point unpack(long id) {
return new Point((int) (id >> 32), (int) id);
}
private static String flipVertical(String text) {
String[] lines = text.split("\\n", -1);
List<String> out = Arrays.asList(lines);
Collections.reverse(out);
return String.join("\n", out);
}
private static String renderSequence(String text, boolean useAscii) {
List<String> lines = splitLines(text);
SequenceDiagram diagram = parseSequence(text);
if (diagram.actors.isEmpty()) return "";
char H = useAscii ? '-' : '─';
char V = useAscii ? '|' : '│';
char TL = useAscii ? '+' : '┌';
char TR = useAscii ? '+' : '┐';
char BL = useAscii ? '+' : '└';
char BR = useAscii ? '+' : '┘';
char JT = useAscii ? '+' : '┬';
char JB = useAscii ? '+' : '┴';
char JL = useAscii ? '+' : '├';
char JR = useAscii ? '+' : '┤';
Map<String, Integer> actorIdx = new HashMap<>();
for (int i = 0; i < diagram.actors.size(); i++) actorIdx.put(diagram.actors.get(i).id, i);
int boxPad = 1;
int actorBoxH = 3;
int n = diagram.actors.size();
int[] actorBoxW = new int[n];
int[] halfBox = new int[n];
for (int i = 0; i < n; i++) {
actorBoxW[i] = diagram.actors.get(i).label.length() + 2 * boxPad + 2;
halfBox[i] = (actorBoxW[i] + 1) / 2;
}
int[] adjMaxW = new int[Math.max(n - 1, 0)];
for (SeqMessage msg : diagram.messages) {
Integer fi = actorIdx.get(msg.from);
Integer ti = actorIdx.get(msg.to);
if (fi == null || ti == null || Objects.equals(fi, ti)) continue;
int lo = Math.min(fi, ti);
int hi = Math.max(fi, ti);
int needed = msg.label.length() + 4;
int numGaps = hi - lo;
int perGap = (needed + numGaps - 1) / numGaps;
for (int g = lo; g < hi; g++) adjMaxW[g] = Math.max(adjMaxW[g], perGap);
}
int[] llx = new int[n];
llx[0] = halfBox[0];
for (int i = 1; i < n; i++) {
int gap = Math.max(Math.max(halfBox[i - 1] + halfBox[i] + 2, adjMaxW[i - 1] + 2), 8);
llx[i] = llx[i - 1] + gap;
}
List<Integer> msgArrowY = new ArrayList<>();
List<Integer> msgLabelY = new ArrayList<>();
Map<Integer, Integer> blockStartY = new HashMap<>();
Map<Integer, Integer> blockEndY = new HashMap<>();
Map<String, Integer> divYMap = new HashMap<>();
List<NotePos> notePositions = new ArrayList<>();
int curY = actorBoxH;
for (int mIdx = 0; mIdx < diagram.messages.size(); mIdx++) {
for (int bIdx = 0; bIdx < diagram.blocks.size(); bIdx++) {
Block block = diagram.blocks.get(bIdx);
if (block.startIndex == mIdx) {
curY += 2;
blockStartY.put(bIdx, curY - 1);
}
}
for (int bIdx = 0; bIdx < diagram.blocks.size(); bIdx++) {
Block block = diagram.blocks.get(bIdx);
for (int dIdx = 0; dIdx < block.dividers.size(); dIdx++) {
BlockDivider div = block.dividers.get(dIdx);
if (div.index == mIdx) {
curY += 1;
divYMap.put(bIdx + ":" + dIdx, curY);
curY += 1;
}
}
}
curY += 1;
SeqMessage msg = diagram.messages.get(mIdx);
boolean self = msg.from.equals(msg.to);
if (self) {
msgLabelY.add(curY + 1);
msgArrowY.add(curY);
curY += 3;
} else {
msgLabelY.add(curY);
msgArrowY.add(curY + 1);
curY += 2;
}
for (SeqNote note : diagram.notes) {
if (note.afterIndex == mIdx) {
curY += 1;
String[] nLines = note.text.split("\\\\n", -1);
int nWidth = 0;
for (String nl : nLines) nWidth = Math.max(nWidth, nl.length());
nWidth += 4;
int nHeight = nLines.length + 2;
int aIdx = actorIdx.getOrDefault(note.actorIds.get(0), 0);
int nx;
if ("left".equals(note.pos)) nx = llx[aIdx] - nWidth - 1;
else if ("right".equals(note.pos)) nx = llx[aIdx] + 2;
else {
if (note.actorIds.size() >= 2) {
int a2 = actorIdx.getOrDefault(note.actorIds.get(1), aIdx);
nx = (llx[aIdx] + llx[a2]) / 2 - (nWidth / 2);
} else {
nx = llx[aIdx] - (nWidth / 2);
}
}
nx = Math.max(0, nx);
notePositions.add(new NotePos(nx, curY, nWidth, nHeight, Arrays.asList(nLines)));
curY += nHeight;
}
}
for (int bIdx = 0; bIdx < diagram.blocks.size(); bIdx++) {
Block block = diagram.blocks.get(bIdx);
if (block.endIndex == mIdx) {
curY += 1;
blockEndY.put(bIdx, curY);
curY += 1;
}
}
}
curY += 1;
int footerY = curY;
int totalH = footerY + actorBoxH;
int totalW = llx[n - 1] + halfBox[n - 1] + 2;
for (SeqMessage msg : diagram.messages) {
if (msg.from.equals(msg.to)) {
int fi = actorIdx.getOrDefault(msg.from, 0);
int selfRight = llx[fi] + 8 + msg.label.length();
totalW = Math.max(totalW, selfRight + 1);
}
}
for (NotePos np : notePositions) totalW = Math.max(totalW, np.x + np.width + 1);
Canvas canvas = new Canvas(totalW + 1, totalH);
for (int i = 0; i < n; i++) {
int x = llx[i];
for (int y = actorBoxH; y <= footerY; y++) canvas.put(x, y, V);
}
for (int i = 0; i < n; i++) {
drawActorBox(canvas, llx[i], 0, diagram.actors.get(i).label, boxPad, H, V, TL, TR, BL, BR);
drawActorBox(canvas, llx[i], footerY, diagram.actors.get(i).label, boxPad, H, V, TL, TR, BL, BR);
if (!useAscii) {
canvas.put(llx[i], actorBoxH - 1, JT);
canvas.put(llx[i], footerY, JB);
}
}
for (int mIdx = 0; mIdx < diagram.messages.size(); mIdx++) {
SeqMessage msg = diagram.messages.get(mIdx);
int fi = actorIdx.get(msg.from);
int ti = actorIdx.get(msg.to);
int fromX = llx[fi];
int toX = llx[ti];
boolean self = fi == ti;
char lineChar = msg.dashed ? (useAscii ? '.' : '╌') : H;
boolean filled = msg.filled;
if (self) {
int topY = msgArrowY.get(mIdx);
int midY = msgLabelY.get(mIdx);
int botY = topY + 2;
int loopX = fromX + 6;
canvas.put(fromX, topY, useAscii ? '+' : JL);
for (int x = fromX + 1; x < loopX; x++) canvas.put(x, topY, lineChar);
canvas.put(loopX, topY, useAscii ? '+' : TR);
for (int y = topY + 1; y < botY; y++) canvas.put(loopX, y, V);
canvas.put(loopX, botY, useAscii ? '+' : BL);
for (int x = fromX + 1; x < loopX; x++) canvas.put(x, botY, lineChar);
canvas.put(fromX, botY, useAscii ? '<' : (filled ? '◄' : '◁'));
canvas.putText(fromX + 2, midY, msg.label);
continue;
}
int labelY = msgLabelY.get(mIdx);
int arrowY = msgArrowY.get(mIdx);
canvas.putText(Math.min(fromX, toX) + 2, labelY, msg.label);
if (fromX < toX) {
for (int x = fromX + 1; x < toX; x++) canvas.put(x, arrowY, lineChar);
canvas.put(toX, arrowY, useAscii ? '>' : (filled ? '▶' : '▷'));
} else {
for (int x = toX + 1; x < fromX; x++) canvas.put(x, arrowY, lineChar);
canvas.put(toX, arrowY, useAscii ? '<' : (filled ? '◀' : '◁'));
}
}
for (int bIdx = 0; bIdx < diagram.blocks.size(); bIdx++) {
Integer startY = blockStartY.get(bIdx);
Integer endY = blockEndY.get(bIdx);
if (startY == null || endY == null) continue;
int left = Arrays.stream(llx).min().orElse(0);
int right = Arrays.stream(llx).max().orElse(0);
int top = startY;
int bottom = endY;
canvas.put(left - 2, top, TL);
for (int x = left - 1; x <= right + 1; x++) canvas.put(x, top, H);
canvas.put(right + 2, top, TR);
canvas.put(left - 2, bottom, BL);
for (int x = left - 1; x <= right + 1; x++) canvas.put(x, bottom, H);
canvas.put(right + 2, bottom, BR);
for (int y = top + 1; y < bottom; y++) {
canvas.put(left - 2, y, V);
canvas.put(right + 2, y, V);
}
String header = (diagram.blocks.get(bIdx).type + " " + diagram.blocks.get(bIdx).label).trim();
canvas.putText(left - 1, top + 1, header);
for (int dIdx = 0; dIdx < diagram.blocks.get(bIdx).dividers.size(); dIdx++) {
Integer dy = divYMap.get(bIdx + ":" + dIdx);
if (dy == null) continue;
canvas.put(left - 2, dy, useAscii ? '+' : JL);
for (int x = left - 1; x <= right + 1; x++) canvas.put(x, dy, H);
canvas.put(right + 2, dy, useAscii ? '+' : JR);
String dl = diagram.blocks.get(bIdx).dividers.get(dIdx).label.trim();
canvas.putText(left - 1, dy + 1, dl);
}
}
for (NotePos np : notePositions) {
canvas.put(np.x, np.y, TL);
for (int x = 1; x < np.width - 1; x++) canvas.put(np.x + x, np.y, H);
canvas.put(np.x + np.width - 1, np.y, TR);
canvas.put(np.x, np.y + np.height - 1, BL);
for (int x = 1; x < np.width - 1; x++) canvas.put(np.x + x, np.y + np.height - 1, H);
canvas.put(np.x + np.width - 1, np.y + np.height - 1, BR);
for (int y = 1; y < np.height - 1; y++) {
canvas.put(np.x, np.y + y, V);
canvas.put(np.x + np.width - 1, np.y + y, V);
}
for (int i = 0; i < np.lines.size(); i++) canvas.putText(np.x + 2, np.y + 1 + i, np.lines.get(i));
}
return canvasToStringFull(canvas, totalW, totalH);
}
private static void drawActorBox(Canvas canvas, int cx, int topY, String label, int boxPad,
char H, char V, char TL, char TR, char BL, char BR) {
int w = label.length() + 2 * boxPad + 2;
int left = cx - (w / 2);
canvas.put(left, topY, TL);
for (int x = 1; x < w - 1; x++) canvas.put(left + x, topY, H);
canvas.put(left + w - 1, topY, TR);
canvas.put(left, topY + 1, V);
canvas.put(left + w - 1, topY + 1, V);
canvas.putText(left + 1 + boxPad, topY + 1, label);
canvas.put(left, topY + 2, BL);
for (int x = 1; x < w - 1; x++) canvas.put(left + x, topY + 2, H);
canvas.put(left + w - 1, topY + 2, BR);
}
private static void drawSeqBox(Canvas c, int x, int y, int w, int h, String label,
char TL, char TR, char BL, char BR, char H, char V) {
c.put(x, y, TL);
c.put(x + w - 1, y, TR);
c.put(x, y + h - 1, BL);
c.put(x + w - 1, y + h - 1, BR);
c.hLine(x + 1, x + w - 2, y, H);
c.hLine(x + 1, x + w - 2, y + h - 1, H);
c.vLine(y + 1, y + h - 2, x, V);
c.vLine(y + 1, y + h - 2, x + w - 1, V);
c.putText(x + Math.max(1, (w - label.length()) / 2), y + 1, label);
}
private static SequenceDiagram parseSequence(String text) {
List<String> lines = splitLines(text);
SequenceDiagram diagram = new SequenceDiagram();
Set<String> actorIds = new HashSet<>();
List<Map<String, Object>> blockStack = new ArrayList<>();
Pattern participantRe = Pattern.compile("^(participant|actor)\\s+(\\S+)(?:\\s+as\\s+(.+))?$", Pattern.CASE_INSENSITIVE);
Pattern noteRe = Pattern.compile("^Note\\s+(right of|left of|over)\\s+(.+?)\\s*:\\s*(.+)$", Pattern.CASE_INSENSITIVE);
Pattern blockStartRe = Pattern.compile("^(loop|alt|opt|par|critical)\\s*(.*)$");
Pattern dividerRe = Pattern.compile("^(else|and)\\s*(.*)$");
Pattern msgRe = Pattern.compile("^(\\S+?)\\s*(--?>?>|--?[)x]|--?>>|--?>)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$");
Pattern simpleMsgRe = Pattern.compile("^(\\S+?)\\s*(->>|-->>|-\\)|--\\)|-x|--x|->|-->)\\s*([+-]?)(\\S+?)\\s*:\\s*(.+)$");
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i);
Matcher pm = participantRe.matcher(line);
if (pm.matches()) {
String type = pm.group(1).toLowerCase();
String id = pm.group(2);
String label = pm.group(3) != null ? pm.group(3).trim() : id;
if (actorIds.add(id)) diagram.actors.add(new SeqActor(id, label, type));
continue;
}
Matcher nm = noteRe.matcher(line);
if (nm.matches()) {
String posRaw = nm.group(1).toLowerCase();
String pos = posRaw.startsWith("right") ? "right" : (posRaw.startsWith("left") ? "left" : "over");
String[] actorTokens = nm.group(2).split(",");
List<String> nActors = new ArrayList<>();
for (String t : actorTokens) {
String id = t.trim();
if (id.isEmpty()) continue;
nActors.add(id);
ensureActor(diagram, actorIds, id);
}
String txt = nm.group(3).trim().replace("<br/>", "\\n");
diagram.notes.add(new SeqNote(nActors, txt, pos, diagram.messages.size() - 1));
continue;
}
Matcher bm = blockStartRe.matcher(line);
if (bm.matches()) {
Map<String, Object> b = new HashMap<>();
b.put("type", bm.group(1));
b.put("label", (bm.group(2) == null ? "" : bm.group(2)).trim());
b.put("startIndex", diagram.messages.size());
b.put("dividers", new ArrayList<BlockDivider>());
blockStack.add(b);
continue;
}
Matcher dm = dividerRe.matcher(line);
if (dm.matches() && !blockStack.isEmpty()) {
String label = (dm.group(2) == null ? "" : dm.group(2)).trim();
@SuppressWarnings("unchecked")
List<BlockDivider> divs = (List<BlockDivider>) blockStack.get(blockStack.size() - 1).get("dividers");
divs.add(new BlockDivider(diagram.messages.size(), label));
continue;
}
if ("end".equals(line) && !blockStack.isEmpty()) {
Map<String, Object> b = blockStack.remove(blockStack.size() - 1);
Block out = new Block();
out.type = (String) b.get("type");
out.label = (String) b.get("label");
out.startIndex = (Integer) b.get("startIndex");
out.endIndex = Math.max(diagram.messages.size() - 1, out.startIndex);
@SuppressWarnings("unchecked")
List<BlockDivider> divs = (List<BlockDivider>) b.get("dividers");
out.dividers.addAll(divs);
diagram.blocks.add(out);
continue;
}
Matcher mm = msgRe.matcher(line);
if (!mm.matches()) mm = simpleMsgRe.matcher(line);
if (mm.matches()) {
String from = mm.group(1);
String arrow = mm.group(2);
String actMark = mm.group(3);
String to = mm.group(4);
String label = mm.group(5).trim();
ensureActor(diagram, actorIds, from);
ensureActor(diagram, actorIds, to);
boolean dashed = arrow.startsWith("--");
boolean filled = (arrow.contains(">>") || arrow.contains("x"));
SeqMessage msg = new SeqMessage(from, to, label, dashed, filled);
msg.activate = "+".equals(actMark);
msg.deactivate = "-".equals(actMark);
diagram.messages.add(msg);
}
}
return diagram;
}
private static void ensureActor(SequenceDiagram diagram, Set<String> actorIds, String actorId) {
if (!actorIds.contains(actorId)) {
actorIds.add(actorId);
diagram.actors.add(new SeqActor(actorId, actorId, "participant"));
}
}
private static ParsedMessage parseSequenceMessage(String line) {
String[] ops = {"-->>", "->>", "--)", "-)", "-->", "->"};
for (String op : ops) {
int idx = line.indexOf(op);
if (idx > 0) {
String left = line.substring(0, idx).trim();
String rest = line.substring(idx + op.length()).trim();
String right;
String label = "";
int c = rest.indexOf(':');
if (c >= 0) {
right = rest.substring(0, c).trim();
label = rest.substring(c + 1).trim();
} else {
right = rest.trim();
}
if (left.isEmpty() || right.isEmpty()) return null;
boolean dashed = op.contains("--");
boolean filled = op.contains(">>") || op.contains("-->") || op.contains("->");
return new ParsedMessage(left, right, label, dashed, filled);
}
}
return null;
}
private static String renderClassDiagram(String text, boolean useAscii) {
ClassDiagram d = parseClass(text);
if (d.classes.isEmpty()) return "";
int hGap = 4;
int vGap = 3;
Map<String, Box> placed = new LinkedHashMap<>();
Map<String, List<List<String>>> classSections = new HashMap<>();
Map<String, Integer> classBoxW = new HashMap<>();
Map<String, Integer> classBoxH = new HashMap<>();
for (ClassNode cls : d.classes.values()) {
List<List<String>> sections = buildClassSections(cls);
classSections.put(cls.id, sections);
BoxSize sz = computeMultiBoxSize(sections, 1);
classBoxW.put(cls.id, sz.w);
classBoxH.put(cls.id, sz.h);
}
Map<String, Set<String>> parents = new HashMap<>();
Map<String, Set<String>> children = new HashMap<>();
for (ClassRel rel : d.relationships) {
boolean hier = "inheritance".equals(rel.type) || "realization".equals(rel.type);
String parentId = (hier && "to".equals(rel.markerAt)) ? rel.to : rel.from;
String childId = (hier && "to".equals(rel.markerAt)) ? rel.from : rel.to;
parents.computeIfAbsent(childId, k -> new LinkedHashSet<>()).add(parentId);
children.computeIfAbsent(parentId, k -> new LinkedHashSet<>()).add(childId);
}
Map<String, Integer> level = new HashMap<>();
List<String> queue = new ArrayList<>();
for (ClassNode cls : d.classes.values()) {
if (!parents.containsKey(cls.id) || parents.get(cls.id).isEmpty()) {
queue.add(cls.id);
level.put(cls.id, 0);
}
}
int levelCap = Math.max(d.classes.size() - 1, 0);
int qi = 0;
while (qi < queue.size()) {
String cid = queue.get(qi++);
Set<String> childSet = children.get(cid);
if (childSet == null) continue;
for (String childId : childSet) {
int newLevel = level.getOrDefault(cid, 0) + 1;
if (newLevel > levelCap) continue;
if (!level.containsKey(childId) || level.get(childId) < newLevel) {
level.put(childId, newLevel);
queue.add(childId);
}
}
}
for (ClassNode cls : d.classes.values()) level.putIfAbsent(cls.id, 0);
int maxLevel = 0;
for (int lv : level.values()) maxLevel = Math.max(maxLevel, lv);
List<List<String>> levelGroups = new ArrayList<>();
for (int i = 0; i <= maxLevel; i++) levelGroups.add(new ArrayList<>());
for (ClassNode cls : d.classes.values()) levelGroups.get(level.get(cls.id)).add(cls.id);
int currentY = 0;
for (int lv = 0; lv <= maxLevel; lv++) {
List<String> group = levelGroups.get(lv);
if (group.isEmpty()) continue;
int currentX = 0;
int maxH = 0;
for (String cid : group) {
int w = classBoxW.getOrDefault(cid, 8);
int h = classBoxH.getOrDefault(cid, 5);
placed.put(cid, new Box(currentX, currentY, w, h, new ArrayList<>()));
currentX += w + hGap;
maxH = Math.max(maxH, h);
}
currentY += maxH + vGap;
}
int totalW = placed.values().stream().mapToInt(b -> b.x + b.w).max().orElse(80) + 2;
int totalH = placed.values().stream().mapToInt(b -> b.y + b.h).max().orElse(30) + 2;
Canvas c = new Canvas(totalW, totalH);
for (Map.Entry<String, Box> ent : placed.entrySet()) {
String cid = ent.getKey();
Box b = ent.getValue();
drawMultiBoxAt(c, b.x, b.y, classSections.get(cid), useAscii, 1);
}
List<PendingArrow> pendingMarkers = new ArrayList<>();
List<PendingLabel> pendingLabels = new ArrayList<>();
List<int[]> labelSpans = new ArrayList<>();
for (ClassRel r : d.relationships) {
Box a = placed.get(r.from);
Box b = placed.get(r.to);
if (a == null || b == null) continue;
int sx = a.x + a.w / 2;
int sy = a.y + a.h;
int ex = b.x + b.w / 2;
int ey = b.y - 1;
char h = (r.type.equals("dependency") || r.type.equals("realization")) ? (useAscii ? '.' : '╌') : (useAscii ? '-' : '─');
char v = (r.type.equals("dependency") || r.type.equals("realization")) ? (useAscii ? ':' : '┊') : (useAscii ? '|' : '│');
int my = (sy + ey) / 2;
Set<String> skipBoxes = new HashSet<>(Arrays.asList(r.from, r.to));
if (hSegmentHitsBox(placed, my, sx, ex, skipBoxes)) {
for (int delta = 1; delta <= totalH; delta++) {
boolean moved = false;
int[] candidates = {my - delta, my + delta};
for (int candidate : candidates) {
if (candidate < 0 || candidate >= totalH) continue;
if (hSegmentHitsBox(placed, candidate, sx, ex, skipBoxes)) continue;
my = candidate;
moved = true;
break;
}
if (moved) break;
}
}
// Keep Python behavior exactly: vertical segments are only drawn for increasing ranges.
for (int y = sy; y <= my; y++) c.put(sx, y, v);
int step = ex >= sx ? 1 : -1;
for (int x = sx; x != ex + step; x += step) c.put(x, my, h);
for (int y = my; y <= ey; y++) c.put(ex, y, v);
String dir = "up";
int markerX;
int markerY;
if ("from".equals(r.markerAt)) {
dir = "down";
markerX = sx;
markerY = sy - 1;
} else {
dir = "up";
markerX = ex;
markerY = ey + 1;
}
char marker = markerForClassRel(r.type, useAscii, dir);
pendingMarkers.add(new PendingArrow(markerX, markerY, marker));
if (r.label != null && !r.label.isBlank()) {
int tx = Math.max(0, (sx + ex) / 2 - r.label.length() / 2);
int ty = my - 1;
int lx1 = tx;
int lx2 = tx + r.label.length() - 1;
boolean placedLabel = false;
int[] dys = {0, -1, 1, -2, 2};
for (int dy : dys) {
int cy = ty + dy;
if (cy < 0 || cy >= totalH) continue;
boolean overlap = false;
for (int[] span : labelSpans) {
if (span[0] == cy && !(lx2 < span[1] || lx1 > span[2])) {
overlap = true;
break;
}
}
if (overlap) continue;
pendingLabels.add(new PendingLabel(tx, cy, r.label));
labelSpans.add(new int[]{cy, lx1, lx2});
placedLabel = true;
break;
}
if (!placedLabel) pendingLabels.add(new PendingLabel(tx, ty, r.label));
}
if (r.fromCardinality != null && !r.fromCardinality.isBlank()) {
int tx = sx - r.fromCardinality.length() - 1;
int ty = sy - 1;
putTextClipped(c, tx, ty, r.fromCardinality, totalW, totalH);
}
if (r.toCardinality != null && !r.toCardinality.isBlank()) {
int tx = ex + 1;
int ty = ey + 1;
putTextClipped(c, tx, ty, r.toCardinality, totalW, totalH);
}
}
for (PendingArrow m : pendingMarkers) c.put(m.x, m.y, m.ch);
for (PendingLabel l : pendingLabels) putTextClipped(c, l.x, l.y, l.text, totalW, totalH);
return canvasToStringFull(c, totalW, totalH);
}
private static boolean hSegmentHitsBox(Map<String, Box> placed, int y, int x1, int x2, Set<String> skip) {
int a = Math.min(x1, x2);
int b = Math.max(x1, x2);
for (Map.Entry<String, Box> e : placed.entrySet()) {
if (skip.contains(e.getKey())) continue;
Box box = e.getValue();
int bx0 = box.x, by0 = box.y, bx1 = box.x + box.w - 1, by1 = box.y + box.h - 1;
if (by0 <= y && y <= by1 && !(b < bx0 || a > bx1)) return true;
}
return false;
}
private static void putTextClipped(Canvas c, int x, int y, String s, int maxW, int maxH) {
if (s == null || s.isEmpty() || y < 0 || y >= maxH) return;
for (int i = 0; i < s.length(); i++) {
int xx = x + i;
if (xx >= 0 && xx < maxW) c.put(xx, y, s.charAt(i));
}
}
private static List<List<String>> buildClassSections(ClassNode cls) {
List<String> header = new ArrayList<>();
if (cls.annotation != null && !cls.annotation.isBlank()) header.add("<<" + cls.annotation + ">>");
header.add(cls.label);
List<String> attrs = new ArrayList<>();
for (String a : cls.attributes) if (a != null && !a.trim().isEmpty()) attrs.add(a.trim());
List<String> methods = new ArrayList<>();
for (String m : cls.methods) if (m != null && !m.trim().isEmpty()) methods.add(m.trim());
if (attrs.isEmpty() && methods.isEmpty()) {
return new ArrayList<>(List.of(header));
}
if (methods.isEmpty()) {
return new ArrayList<>(List.of(header, attrs));
}
return new ArrayList<>(List.of(header, attrs, methods));
}
private static char markerForClassRel(String type, boolean useAscii, String dir) {
switch (type) {
case "inheritance":
case "realization":
if ("down".equals(dir)) return useAscii ? '^' : '△';
if ("up".equals(dir)) return useAscii ? 'v' : '▽';
if ("left".equals(dir)) return useAscii ? '>' : '◁';
return useAscii ? '<' : '▷';
case "composition":
return useAscii ? '*' : '◆';
case "aggregation":
return useAscii ? 'o' : '◇';
default:
if ("down".equals(dir)) return useAscii ? 'v' : '▼';
if ("up".equals(dir)) return useAscii ? '^' : '▲';
if ("left".equals(dir)) return useAscii ? '<' : '◀';
return useAscii ? '>' : '▶';
}
}
private static ClassDiagram parseClass(String text) {
List<String> lines = splitLines(text);
ClassDiagram d = new ClassDiagram();
ClassNode current = null;
int braceDepth = 0;
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i);
if (current != null && braceDepth > 0) {
if ("}".equals(line)) {
braceDepth--;
if (braceDepth == 0) current = null;
continue;
}
if (line.startsWith("<<") && line.endsWith(">>")) {
String ann = line.substring(2, line.length() - 2).trim();
if (!ann.isEmpty()) current.annotation = ann;
continue;
}
ParsedClassMember member = parseClassMember(line);
if (member != null) {
if (member.isMethod) current.methods.add(member.text);
else current.attributes.add(member.text);
}
continue;
}
Matcher cb = Pattern.compile("^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*\\{$").matcher(line);
if (cb.matches()) {
String id = cb.group(1);
String generic = cb.group(2);
current = d.classes.computeIfAbsent(id, k -> new ClassNode(id, id));
if (generic != null && !generic.isBlank()) current.label = id + "<" + generic + ">";
braceDepth = 1;
continue;
}
Matcher co = Pattern.compile("^class\\s+(\\S+?)(?:\\s*~(\\w+)~)?\\s*$").matcher(line);
if (co.matches()) {
String id = co.group(1);
String generic = co.group(2);
ClassNode cls = d.classes.computeIfAbsent(id, k -> new ClassNode(id, id));
if (generic != null && !generic.isBlank()) cls.label = id + "<" + generic + ">";
continue;
}
Matcher inlineAnnot = Pattern.compile("^class\\s+(\\S+?)\\s*\\{\\s*<<(\\w+)>>\\s*\\}$").matcher(line);
if (inlineAnnot.matches()) {
String id = inlineAnnot.group(1);
ClassNode cls = d.classes.computeIfAbsent(id, k -> new ClassNode(id, id));
cls.annotation = inlineAnnot.group(2);
continue;
}
Matcher inlineAttr = Pattern.compile("^(\\S+?)\\s*:\\s*(.+)$").matcher(line);
if (inlineAttr.matches()) {
String rest = inlineAttr.group(2);
if (!rest.matches(".*(<\\|--|--|\\*--|o--|-->|\\.\\.>|\\.\\.\\|>).*$")) {
ClassNode cls = d.classes.computeIfAbsent(inlineAttr.group(1), k -> new ClassNode(inlineAttr.group(1), inlineAttr.group(1)));
ParsedClassMember member = parseClassMember(rest);
if (member != null) {
if (member.isMethod) cls.methods.add(member.text);
else cls.attributes.add(member.text);
}
continue;
}
}
ClassRel rel = parseClassRelLine(line);
if (rel != null) {
d.classes.computeIfAbsent(rel.from, k -> new ClassNode(rel.from, rel.from));
d.classes.computeIfAbsent(rel.to, k -> new ClassNode(rel.to, rel.to));
d.relationships.add(rel);
}
}
return d;
}
private static ClassRel parseClassRelLine(String line) {
Pattern p = Pattern.compile("^(\\S+?)\\s+(?:\"([^\"]*?)\"\\s+)?(<\\|--|<\\|\\.\\.|\\*--|o--|-->|--\\*|--o|--|\\.\\.>|\\.\\.\\|>|--)\\s+(?:\"([^\"]*?)\"\\s+)?(\\S+?)(?:\\s*:\\s*(.+))?$");
Matcher m = p.matcher(line);
if (!m.matches()) return null;
String from = m.group(1);
String fromCard = m.group(2);
String arrow = m.group(3).trim();
String toCard = m.group(4);
String to = m.group(5);
String label = m.group(6) == null ? null : m.group(6).trim();
ArrowParsed ap = parseClassArrow(arrow);
if (ap == null) return null;
return new ClassRel(from, to, ap.type, ap.markerAt, label, fromCard, toCard);
}
private static ArrowParsed parseClassArrow(String arrow) {
if ("<|--".equals(arrow)) return new ArrowParsed("inheritance", "from");
if ("<|..".equals(arrow)) return new ArrowParsed("realization", "from");
if ("*--".equals(arrow)) return new ArrowParsed("composition", "from");
if ("--*".equals(arrow)) return new ArrowParsed("composition", "to");
if ("o--".equals(arrow)) return new ArrowParsed("aggregation", "from");
if ("--o".equals(arrow)) return new ArrowParsed("aggregation", "to");
if ("-->".equals(arrow)) return new ArrowParsed("association", "to");
if ("..>".equals(arrow)) return new ArrowParsed("dependency", "to");
if ("..|>".equals(arrow)) return new ArrowParsed("realization", "to");
if ("--".equals(arrow)) return new ArrowParsed("association", "to");
return null;
}
private static ParsedClassMember parseClassMember(String line) {
String trimmed = line.trim();
while (trimmed.endsWith(";")) trimmed = trimmed.substring(0, trimmed.length() - 1).trim();
if (trimmed.isEmpty()) return null;
String visibility = "";
String rest = trimmed;
if ("+-#~".indexOf(rest.charAt(0)) >= 0) {
visibility = rest.substring(0, 1);
rest = rest.substring(1).trim();
}
Matcher mm = Pattern.compile("^(.+?)\\(([^)]*)\\)(?:\\s*(.+))?$").matcher(rest);
if (mm.matches()) {
String name = mm.group(1).trim();
String typ = mm.group(3) == null ? "" : mm.group(3).trim();
boolean isStatic = name.endsWith("$") || rest.contains("$");
boolean isAbstract = name.endsWith("*") || rest.contains("*");
name = name.replace("$", "").replace("*", "");
String text = visibility + name + (typ.isEmpty() ? "" : ": " + typ);
return new ParsedClassMember(text, true, isStatic, isAbstract);
}
String[] parts = rest.split("\\s+");
String name;
String typ = "";
if (parts.length >= 2) {
name = parts[0];
typ = String.join(" ", Arrays.copyOfRange(parts, 1, parts.length));
} else {
name = rest;
}
if (name.endsWith(":")) name = name.substring(0, name.length() - 1);
boolean isStatic = name.endsWith("$");
boolean isAbstract = name.endsWith("*");
name = name.replace("$", "").replace("*", "");
String text = visibility + name + (typ.isEmpty() ? "" : ": " + typ);
return new ParsedClassMember(text, false, isStatic, isAbstract);
}
private static String renderErDiagram(String text, boolean useAscii) {
ErDiagram d = parseEr(text);
if (d.entities.isEmpty()) return "";
int perRow = Math.max(2, (int) Math.ceil(Math.sqrt(d.entities.size())));
int hGap = 6;
int vGap = 3;
Map<String, Box> placed = new LinkedHashMap<>();
Map<String, List<List<String>>> entitySections = new HashMap<>();
int x = 0, y = 0, col = 0, maxH = 0;
for (ErEntity e : d.entities.values()) {
List<List<String>> sections = buildEntitySections(e);
entitySections.put(e.id, sections);
BoxSize sz = computeMultiBoxSize(sections, 1);
int w = sz.w;
int h = sz.h;
placed.put(e.id, new Box(x, y, w, h, new ArrayList<>()));
maxH = Math.max(maxH, h);
x += w + hGap;
col++;
if (col >= perRow) {
col = 0;
x = 0;
y += maxH + vGap;
maxH = 0;
}
}
int totalW = placed.values().stream().mapToInt(b -> b.x + b.w).max().orElse(80) + 4;
int totalH = placed.values().stream().mapToInt(b -> b.y + b.h).max().orElse(30) + 2;
Canvas c = new Canvas(totalW, totalH);
for (Map.Entry<String, Box> ent : placed.entrySet()) {
drawMultiBoxAt(c, ent.getValue().x, ent.getValue().y, entitySections.get(ent.getKey()), useAscii, 1);
}
char H = useAscii ? '-' : '─';
char V = useAscii ? '|' : '│';
char dH = useAscii ? '.' : '╌';
char dV = useAscii ? ':' : '┊';
for (ErRel r : d.relationships) {
Box a = placed.get(r.e1);
Box b = placed.get(r.e2);
if (a == null || b == null) continue;
int ax = a.x + a.w / 2;
int ay = a.y + a.h / 2;
int bx = b.x + b.w / 2;
int by = b.y + b.h / 2;
boolean sameRow = Math.abs(ay - by) < Math.max(a.h, b.h);
char hChar = r.identifying ? H : dH;
char vChar = r.identifying ? V : dV;
if (sameRow) {
Box left = ax < bx ? a : b;
Box right = ax < bx ? b : a;
String leftCard = ax < bx ? r.c1 : r.c2;
String rightCard = ax < bx ? r.c2 : r.c1;
int startX = left.x + left.w;
int endX = right.x - 1;
int lineY = left.y + left.h / 2;
for (int xx = startX; xx <= endX; xx++) c.put(xx, lineY, hChar);
String leftChars = cardChars(leftCard, useAscii);
for (int i = 0; i < leftChars.length(); i++) c.put(startX + i, lineY, leftChars.charAt(i));
String rightChars = cardChars(rightCard, useAscii);
for (int i = 0; i < rightChars.length(); i++) c.put(endX - rightChars.length() + 1 + i, lineY, rightChars.charAt(i));
if (r.label != null && !r.label.isBlank()) {
int gapMid = (startX + endX) / 2;
int labelStart = Math.max(startX, gapMid - r.label.length() / 2);
int labelY = lineY - 1;
if (labelY >= 0) {
for (int i = 0; i < r.label.length(); i++) {
int lx = labelStart + i;
if (lx >= startX && lx <= endX) c.put(lx, labelY, r.label.charAt(i));
}
}
}
} else {
Box upper = ay < by ? a : b;
Box lower = ay < by ? b : a;
String upperCard = ay < by ? r.c1 : r.c2;
String lowerCard = ay < by ? r.c2 : r.c1;
int startY = upper.y + upper.h;
int endY = lower.y - 1;
int lineX = upper.x + upper.w / 2;
for (int yy = startY; yy <= endY; yy++) c.put(lineX, yy, vChar);
String upChars = cardChars(upperCard, useAscii);
if (useAscii) {
for (int i = 0; i < upChars.length(); i++) c.put(lineX + i, startY, upChars.charAt(i));
} else {
if (upChars.length() == 1) c.put(lineX, startY, upChars.charAt(0));
else {
c.put(lineX - 1, startY, upChars.charAt(0));
c.put(lineX, startY, upChars.charAt(1));
}
}
String lowChars = cardChars(lowerCard, useAscii);
if (useAscii) {
for (int i = 0; i < lowChars.length(); i++) c.put(lineX + i, endY, lowChars.charAt(i));
} else {
if (lowChars.length() == 1) c.put(lineX, endY, lowChars.charAt(0));
else {
c.put(lineX - 1, endY, lowChars.charAt(0));
c.put(lineX, endY, lowChars.charAt(1));
}
}
if (r.label != null && !r.label.isBlank()) {
int labelY = (startY + endY) / 2;
int labelX = lineX + 2;
for (int i = 0; i < r.label.length(); i++) {
int lx = labelX + i;
if (lx < totalW && labelY < totalH) c.put(lx, labelY, r.label.charAt(i));
}
}
}
}
return canvasToStringFull(c, totalW, totalH);
}
private static List<List<String>> buildEntitySections(ErEntity entity) {
List<List<String>> out = new ArrayList<>();
out.add(List.of(entity.label));
List<String> attrs = new ArrayList<>();
for (String a : entity.attributes) if (a != null && !a.trim().isEmpty()) attrs.add(a);
if (!attrs.isEmpty()) out.add(attrs);
return out;
}
private static BoxSize computeMultiBoxSize(List<List<String>> sections, int padding) {
int maxText = 0;
for (List<String> section : sections) {
for (String line : section) maxText = Math.max(maxText, line.stripTrailing().length());
}
int boxW = maxText + 2 * padding + 2;
int totalLines = 0;
for (List<String> section : sections) totalLines += Math.max(section.size(), 1);
int boxH = totalLines + (sections.size() - 1) + 2;
return new BoxSize(boxW, boxH);
}
private static void drawMultiBoxAt(Canvas canvas, int ox, int oy, List<List<String>> sections, boolean useAscii, int padding) {
BoxSize sz = computeMultiBoxSize(sections, padding);
int boxW = sz.w;
int boxH = sz.h;
char hline = useAscii ? '-' : '─';
char vline = useAscii ? '|' : '│';
char tl = useAscii ? '+' : '┌';
char tr = useAscii ? '+' : '┐';
char bl = useAscii ? '+' : '└';
char br = useAscii ? '+' : '┘';
char dl = useAscii ? '+' : '├';
char dr = useAscii ? '+' : '┤';
canvas.put(ox, oy, tl);
for (int x = 1; x < boxW - 1; x++) canvas.put(ox + x, oy, hline);
canvas.put(ox + boxW - 1, oy, tr);
canvas.put(ox, oy + boxH - 1, bl);
for (int x = 1; x < boxW - 1; x++) canvas.put(ox + x, oy + boxH - 1, hline);
canvas.put(ox + boxW - 1, oy + boxH - 1, br);
for (int y = 1; y < boxH - 1; y++) {
canvas.put(ox, oy + y, vline);
canvas.put(ox + boxW - 1, oy + y, vline);
}
int row = 1;
for (int s = 0; s < sections.size(); s++) {
List<String> lines = sections.get(s).isEmpty() ? List.of("") : sections.get(s);
for (String line : lines) {
canvas.putText(ox + 1 + padding, oy + row, line);
row++;
}
if (s < sections.size() - 1) {
canvas.put(ox, oy + row, dl);
for (int x = 1; x < boxW - 1; x++) canvas.put(ox + x, oy + row, hline);
canvas.put(ox + boxW - 1, oy + row, dr);
row++;
}
}
}
private static String cardChars(String card, boolean useAscii) {
if (useAscii) {
switch (card) {
case "one":
return "||";
case "zero-one":
return "o|";
case "many":
return "}|";
case "zero-many":
return "o{";
default:
return "||";
}
}
switch (card) {
case "one":
return "";
case "zero-one":
return "o║";
case "many":
return "";
case "zero-many":
return "o╟";
default:
return "";
}
}
private static ErDiagram parseEr(String text) {
List<String> lines = splitLines(text);
ErDiagram d = new ErDiagram();
ErEntity current = null;
for (int i = 1; i < lines.size(); i++) {
String line = lines.get(i);
if (line.equals("}")) {
current = null;
continue;
}
Matcher em = ER_ENTITY.matcher(line);
if (em.matches()) {
String id = em.group(1);
current = d.entities.computeIfAbsent(id, k -> new ErEntity(id, id));
continue;
}
if (current != null && !line.contains("--") && !line.contains("..")) {
String attr = parseErAttribute(line.trim());
if (attr != null) current.attributes.add(attr);
continue;
}
ErRel rel = parseErRel(line);
if (rel != null) {
d.entities.computeIfAbsent(rel.e1, k -> new ErEntity(rel.e1, rel.e1));
d.entities.computeIfAbsent(rel.e2, k -> new ErEntity(rel.e2, rel.e2));
d.relationships.add(rel);
}
}
return d;
}
private static String parseErAttribute(String line) {
Matcher m = Pattern.compile("^(\\S+)\\s+(\\S+)(?:\\s+(.+))?$").matcher(line);
if (!m.matches()) return null;
String typ = m.group(1);
String name = m.group(2);
String rest = m.group(3) == null ? "" : m.group(3).trim();
String restNoComment = rest.replaceAll("\"[^\"]*\"", "").trim();
List<String> keys = new ArrayList<>();
if (!restNoComment.isEmpty()) {
for (String p : restNoComment.split("\\s+")) {
String u = p.toUpperCase();
if ("PK".equals(u) || "FK".equals(u) || "UK".equals(u)) keys.add(u);
}
}
String keyStr = keys.isEmpty() ? " " : String.join(",", keys) + " ";
return keyStr + typ + " " + name;
}
private static ErRel parseErRel(String line) {
Matcher m = Pattern.compile("^(\\S+)\\s+([|o}{]+(?:--|\\.\\.)[|o}{]+)\\s+(\\S+)\\s*:\\s*(.+)$").matcher(line);
if (!m.matches()) return null;
String e1 = m.group(1);
String cardStr = m.group(2);
String e2 = m.group(3);
String label = m.group(4).trim();
Matcher lm = Pattern.compile("^([|o}{]+)(--|\\.\\.?)([|o}{]+)$").matcher(cardStr);
if (!lm.matches()) return null;
String leftStr = lm.group(1);
String lineStyle = lm.group(2);
String rightStr = lm.group(3);
String c1 = parseCardinality(leftStr);
String c2 = parseCardinality(rightStr);
if (c1 == null || c2 == null) return null;
boolean identifying = "--".equals(lineStyle);
return new ErRel(e1, e2, c1, c2, label, identifying);
}
private static String parseCardinality(String s) {
char[] arr = s.toCharArray();
Arrays.sort(arr);
String sorted = new String(arr);
if ("||".equals(sorted)) return "one";
if ("o|".equals(sorted)) return "zero-one";
if ("|}".equals(sorted) || "{|".equals(sorted)) return "many";
if ("{o".equals(sorted) || "o{".equals(sorted)) return "zero-many";
return null;
}
private static String renderFlowParity(FlowGraph source, boolean useAscii, int paddingX, int paddingY, int boxPad) {
FlowAsciiGraph g = toFlowAsciiGraph(source, useAscii, paddingX, paddingY, boxPad);
createFlowMapping(g);
drawFlowGraph(g);
if ("BT".equals(source.direction)) {
flipCanvasVertically(g.canvas, g.canvasMaxX, g.canvasMaxY);
}
return canvasToStringFull(g.canvas, g.canvasMaxX, g.canvasMaxY);
}
private static FlowAsciiGraph toFlowAsciiGraph(FlowGraph source, boolean useAscii, int paddingX, int paddingY, int boxPad) {
List<FNode> nodes = new ArrayList<>();
Map<String, FNode> nodeMap = new LinkedHashMap<>();
int idx = 0;
for (FlowNode n : source.nodes.values()) {
FNode an = new FNode(n.id, n.label == null ? "" : n.label, idx++);
nodeMap.put(n.id, an);
nodes.add(an);
}
List<FEdge> edges = new ArrayList<>();
for (FlowEdge e : source.edges) {
FNode from = nodeMap.get(e.from);
FNode to = nodeMap.get(e.to);
if (from == null || to == null) continue;
edges.add(new FEdge(from, to, e.label == null ? "" : e.label));
}
List<FSubgraph> subgraphs = new ArrayList<>();
Map<Subgraph, FSubgraph> sgMap = new HashMap<>();
for (Subgraph sg : source.subgraphs) {
FSubgraph asg = sgMap.computeIfAbsent(sg, k -> new FSubgraph(k.label == null ? "" : k.label, new ArrayList<>(), null, new ArrayList<>(), k.direction));
if (!subgraphs.contains(asg)) subgraphs.add(asg);
for (String nid : sg.nodeIds) {
FNode node = nodeMap.get(nid);
if (node != null && !asg.nodes.contains(node)) asg.nodes.add(node);
}
if (sg.parent == null) {
// root subgraph already added above
} else {
FSubgraph parent = sgMap.computeIfAbsent(sg.parent, k -> new FSubgraph(k.label == null ? "" : k.label, new ArrayList<>(), null, new ArrayList<>(), k.direction));
asg.parent = parent;
if (!parent.children.contains(asg)) parent.children.add(asg);
if (!subgraphs.contains(parent)) subgraphs.add(parent);
}
}
for (FSubgraph sg : sgMap.values()) {
for (FSubgraph child : sg.children) {
for (FNode node : child.nodes) {
if (!sg.nodes.contains(node)) sg.nodes.add(node);
}
}
}
Map<String, FSubgraph> nodeOwner = new HashMap<>();
for (Subgraph sg : source.subgraphs) {
if (sg.parent == null) claimSubgraphNodes(sg, sgMap, nodeOwner);
}
for (FSubgraph sg : sgMap.values()) {
List<FNode> filtered = new ArrayList<>();
for (FNode node : sg.nodes) {
FSubgraph owner = nodeOwner.get(node.name);
if (owner == null || isAncestorOrSelfSubgraph(sg, owner)) filtered.add(node);
}
sg.nodes.clear();
sg.nodes.addAll(filtered);
}
String graphDir = ("LR".equals(source.direction) || "RL".equals(source.direction)) ? "LR" : "TD";
FlowConfig cfg = new FlowConfig(useAscii, paddingX, paddingY, boxPad, graphDir);
FlowAsciiGraph out = new FlowAsciiGraph(nodes, edges, new Canvas(1, 1), new HashSet<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), subgraphs, cfg);
for (FNode node : nodes) {
out.nodeByName.put(node.name, node);
}
return out;
}
private static void claimSubgraphNodes(Subgraph mermaidSg, Map<Subgraph, FSubgraph> sgMap, Map<String, FSubgraph> nodeOwner) {
FSubgraph asciiSg = sgMap.get(mermaidSg);
if (asciiSg == null) return;
for (Subgraph child : mermaidSg.children) claimSubgraphNodes(child, sgMap, nodeOwner);
for (String nodeId : mermaidSg.nodeIds) {
if (!nodeOwner.containsKey(nodeId)) nodeOwner.put(nodeId, asciiSg);
}
}
private static boolean isAncestorOrSelfSubgraph(FSubgraph candidate, FSubgraph target) {
FSubgraph cur = target;
while (cur != null) {
if (cur == candidate) return true;
cur = cur.parent;
}
return false;
}
private static void createFlowMapping(FlowAsciiGraph graph) {
String dirn = graph.config.graphDirection;
int[] highest = new int[1024];
highest[0] = 4;
Set<String> found = new HashSet<>();
List<FNode> rootNodes = new ArrayList<>();
for (FNode node : graph.nodes) {
if (isPseudoStateNode(node)) continue;
if (!found.contains(node.name)) rootNodes.add(node);
found.add(node.name);
for (FNode child : getFlowChildren(graph, node)) {
if (!isPseudoStateNode(child)) found.add(child.name);
}
}
boolean hasExternalRoots = false;
boolean hasSubgraphRootsWithEdges = false;
for (FNode node : rootNodes) {
if (isNodeInAnySubgraph(graph, node)) {
if (!getFlowChildren(graph, node).isEmpty()) hasSubgraphRootsWithEdges = true;
} else {
hasExternalRoots = true;
}
}
boolean shouldSeparate = hasExternalRoots && hasSubgraphRootsWithEdges;
List<FNode> externalRoots = new ArrayList<>();
List<FNode> subgraphRoots = new ArrayList<>();
for (FNode n : rootNodes) {
if (shouldSeparate && isNodeInAnySubgraph(graph, n)) subgraphRoots.add(n);
else externalRoots.add(n);
}
for (FNode node : externalRoots) {
FGridCoord req = "LR".equals(dirn) ? new FGridCoord(0, highest[0]) : new FGridCoord(highest[0], 4);
reserveSpotInGrid(graph, node, req);
highest[0] += 4;
}
if (shouldSeparate && !subgraphRoots.isEmpty()) {
int subgraphLevel = "LR".equals(dirn) ? 4 : 10;
for (FNode node : subgraphRoots) {
FGridCoord req = "LR".equals(dirn) ? new FGridCoord(subgraphLevel, highest[subgraphLevel]) : new FGridCoord(highest[subgraphLevel], subgraphLevel);
reserveSpotInGrid(graph, node, req);
highest[subgraphLevel] += 4;
}
}
for (int iter = 0; iter < graph.nodes.size() + 2; iter++) {
boolean changed = false;
for (FNode node : graph.nodes) {
if (node.gridCoord == null) continue;
for (FNode child : getFlowChildren(graph, node)) {
if (child.gridCoord != null) continue;
String effectiveDir = effectiveDirForNodes(graph, node, child, dirn);
int childLevel = "LR".equals(effectiveDir) ? node.gridCoord.x + 4 : node.gridCoord.y + 4;
if (childLevel >= highest.length) childLevel = highest.length - 1;
int basePosition = "LR".equals(effectiveDir) ? node.gridCoord.y : node.gridCoord.x;
int high = Math.max(highest[childLevel], basePosition);
FGridCoord req = "LR".equals(effectiveDir) ? new FGridCoord(childLevel, high) : new FGridCoord(high, childLevel);
reserveSpotInGrid(graph, child, req);
highest[childLevel] = high + 4;
changed = true;
}
}
if (!changed) break;
}
for (int iter = 0; iter < graph.nodes.size() + 2; iter++) {
boolean changed = false;
for (FNode node : graph.nodes) {
if (node.gridCoord != null || !isPseudoStateNode(node)) continue;
List<FNode> outgoing = new ArrayList<>();
List<FNode> incoming = new ArrayList<>();
for (FEdge e : graph.edges) {
if (e.from == node && e.to.gridCoord != null) outgoing.add(e.to);
if (e.to == node && e.from.gridCoord != null) incoming.add(e.from);
}
FNode anchor = !outgoing.isEmpty() ? outgoing.get(0) : (!incoming.isEmpty() ? incoming.get(0) : null);
if (anchor == null) continue;
String effectiveDir = effectiveDirForNodes(graph, node, anchor, dirn);
FGridCoord req;
if (node.name.startsWith("_start") && !outgoing.isEmpty()) {
req = "LR".equals(effectiveDir)
? new FGridCoord(Math.max(0, anchor.gridCoord.x - 2), anchor.gridCoord.y)
: new FGridCoord(anchor.gridCoord.x, Math.max(0, anchor.gridCoord.y - 2));
} else if (node.name.startsWith("_end") && !incoming.isEmpty()) {
req = "LR".equals(effectiveDir)
? new FGridCoord(anchor.gridCoord.x + 2, anchor.gridCoord.y)
: new FGridCoord(anchor.gridCoord.x, anchor.gridCoord.y + 2);
} else {
req = "LR".equals(effectiveDir)
? new FGridCoord(Math.max(0, anchor.gridCoord.x - 2), anchor.gridCoord.y)
: new FGridCoord(anchor.gridCoord.x, Math.max(0, anchor.gridCoord.y - 2));
}
reserveSpotInGrid(graph, node, req);
changed = true;
}
if (!changed) break;
}
for (FNode node : graph.nodes) {
if (node.gridCoord != null) continue;
FGridCoord req = "LR".equals(dirn) ? new FGridCoord(0, highest[0]) : new FGridCoord(highest[0], 4);
reserveSpotInGrid(graph, node, req);
highest[0] += 4;
}
for (FNode node : graph.nodes) setFlowColumnWidth(graph, node);
for (FEdge edge : graph.edges) determineFlowPath(graph, edge);
for (int i = 0; i < 2; i++) {
for (FEdge edge : graph.edges) determineFlowPath(graph, edge);
}
for (FEdge edge : graph.edges) {
increaseGridForPath(graph, edge.path);
determineFlowLabelLine(graph, edge);
}
for (FNode node : graph.nodes) {
node.drawingCoord = gridToDrawingCoord(graph, node.gridCoord, null);
FCanvasSize drawSize = drawFlowBox(graph, node, false);
node.drawW = drawSize.w;
node.drawH = drawSize.h;
}
setFlowCanvasSizeToGrid(graph);
calculateSubgraphBoundingBoxes(graph);
offsetDrawingForSubgraphs(graph);
int reqX = graph.canvasMaxX;
int reqY = graph.canvasMaxY;
for (FNode n : graph.nodes) {
if (n.drawingCoord == null) continue;
reqX = Math.max(reqX, n.drawingCoord.x + n.drawW);
reqY = Math.max(reqY, n.drawingCoord.y + n.drawH);
}
for (FSubgraph sg : graph.subgraphs) {
reqX = Math.max(reqX, sg.maxX);
reqY = Math.max(reqY, sg.maxY);
}
graph.canvasMaxX = reqX;
graph.canvasMaxY = reqY;
graph.canvas.ensure(graph.canvasMaxX, graph.canvasMaxY);
}
private static boolean isPseudoStateNode(FNode node) {
return (node.name.startsWith("_start") || node.name.startsWith("_end")) && node.displayLabel.isEmpty();
}
private static String effectiveDirForNodes(FlowAsciiGraph graph, FNode a, FNode b, String dirn) {
FSubgraph aSg = getNodeSubgraph(graph, a);
FSubgraph bSg = getNodeSubgraph(graph, b);
if (aSg != null && bSg != null && aSg == bSg && aSg.direction != null) {
return ("LR".equals(aSg.direction) || "RL".equals(aSg.direction)) ? "LR" : "TD";
}
return dirn;
}
private static void setFlowCanvasSizeToGrid(FlowAsciiGraph graph) {
int maxX = 0;
int maxY = 0;
for (int w : graph.columnWidth.values()) maxX += w;
for (int h : graph.rowHeight.values()) maxY += h;
graph.canvasMaxX = maxX;
graph.canvasMaxY = maxY;
graph.canvas.ensure(maxX, maxY);
}
private static FGridCoord reserveSpotInGrid(FlowAsciiGraph graph, FNode node, FGridCoord requested) {
int[][] footprint = isPseudoStateNode(node) ? new int[][]{{0, 0}} : buildFootprint3x3();
if (!canPlace(graph, requested, footprint)) {
if ("LR".equals(graph.config.graphDirection)) {
return reserveSpotInGrid(graph, node, new FGridCoord(requested.x, requested.y + 4));
}
return reserveSpotInGrid(graph, node, new FGridCoord(requested.x + 4, requested.y));
}
for (int[] p : footprint) {
long id = pack(requested.x + p[0], requested.y + p[1]);
graph.grid.add(id);
graph.gridOwner.put(id, node);
}
node.gridCoord = requested;
return requested;
}
private static boolean canPlace(FlowAsciiGraph graph, FGridCoord at, int[][] footprint) {
for (int[] p : footprint) {
if (graph.grid.contains(pack(at.x + p[0], at.y + p[1]))) return false;
}
return true;
}
private static int[][] buildFootprint3x3() {
int[][] fp = new int[9][2];
int i = 0;
for (int dx = 0; dx < 3; dx++) {
for (int dy = 0; dy < 3; dy++) {
fp[i][0] = dx;
fp[i][1] = dy;
i++;
}
}
return fp;
}
private static void setFlowColumnWidth(FlowAsciiGraph graph, FNode node) {
if (node.gridCoord == null) return;
int gcx = node.gridCoord.x;
int gcy = node.gridCoord.y;
int padding = graph.config.boxBorderPadding;
int[] cols = {1, 2 * padding + node.displayLabel.length(), 1};
int[] rows = {1, 1 + 2 * padding, 1};
for (int i = 0; i < cols.length; i++) {
int x = gcx + i;
graph.columnWidth.put(x, Math.max(graph.columnWidth.getOrDefault(x, 0), cols[i]));
}
for (int i = 0; i < rows.length; i++) {
int y = gcy + i;
graph.rowHeight.put(y, Math.max(graph.rowHeight.getOrDefault(y, 0), rows[i]));
}
if (gcx > 0) graph.columnWidth.put(gcx - 1, Math.max(graph.columnWidth.getOrDefault(gcx - 1, 0), graph.config.paddingX));
if (gcy > 0) {
int basePad = graph.config.paddingY;
if (hasIncomingEdgeFromOutsideSubgraph(graph, node)) basePad += 4;
graph.rowHeight.put(gcy - 1, Math.max(graph.rowHeight.getOrDefault(gcy - 1, 0), basePad));
}
}
private static void increaseGridForPath(FlowAsciiGraph graph, List<FGridCoord> path) {
int pathPadX = Math.max(1, (graph.config.paddingX + 1) / 3);
int pathPadY = Math.max(1, graph.config.paddingY / 3);
for (FGridCoord c : path) {
graph.columnWidth.putIfAbsent(c.x, pathPadX);
graph.rowHeight.putIfAbsent(c.y, pathPadY);
}
}
private static boolean hasIncomingEdgeFromOutsideSubgraph(FlowAsciiGraph graph, FNode node) {
FSubgraph nodeSg = getNodeSubgraph(graph, node);
if (nodeSg == null || node.gridCoord == null) return false;
boolean hasExternal = false;
for (FEdge edge : graph.edges) {
if (edge.to == node) {
FSubgraph srcSg = getNodeSubgraph(graph, edge.from);
if (srcSg != nodeSg) {
hasExternal = true;
break;
}
}
}
if (!hasExternal) return false;
for (FNode other : nodeSg.nodes) {
if (other == node || other.gridCoord == null) continue;
boolean otherHasExternal = false;
for (FEdge edge : graph.edges) {
if (edge.to == other) {
FSubgraph srcSg = getNodeSubgraph(graph, edge.from);
if (srcSg != nodeSg) {
otherHasExternal = true;
break;
}
}
}
if (otherHasExternal && other.gridCoord.y < node.gridCoord.y) return false;
}
return true;
}
private static boolean isNodeInAnySubgraph(FlowAsciiGraph graph, FNode node) {
for (FSubgraph sg : graph.subgraphs) {
if (sg.nodes.contains(node)) return true;
}
return false;
}
private static FSubgraph getNodeSubgraph(FlowAsciiGraph graph, FNode node) {
FSubgraph owner = null;
int bestDepth = -1;
for (FSubgraph sg : graph.subgraphs) {
if (!sg.nodes.contains(node)) continue;
int d = subgraphDepth(sg);
if (d > bestDepth) {
bestDepth = d;
owner = sg;
}
}
return owner;
}
private static int subgraphDepth(FSubgraph sg) {
int d = 0;
FSubgraph cur = sg.parent;
while (cur != null) {
d++;
cur = cur.parent;
}
return d;
}
private static List<FNode> getFlowChildren(FlowAsciiGraph graph, FNode node) {
List<FNode> out = new ArrayList<>();
for (FEdge e : getFlowEdgesFromNode(graph, node)) out.add(e.to);
return out;
}
private static List<FEdge> getFlowEdgesFromNode(FlowAsciiGraph graph, FNode node) {
List<FEdge> out = new ArrayList<>();
for (FEdge e : graph.edges) if (e.from == node) out.add(e);
return out;
}
private static void determineFlowPath(FlowAsciiGraph graph, FEdge edge) {
FDirSet dirs = determineStartAndEndDir(edge, graph.config.graphDirection);
boolean fromPseudo = isPseudoStateNode(edge.from);
boolean toPseudo = isPseudoStateNode(edge.to);
List<FDir> startDirs = fanoutStartDirs(graph, edge, dirs.prefDir, dirs.altDir, fromPseudo);
List<FDir> endDirs = faninEndDirs(graph, edge, dirs.prefOpp, dirs.altOpp, toPseudo);
List<PathCandidate> candidates = new ArrayList<>();
List<PathCandidate> fallbackCandidates = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (FDir sdir : startDirs) {
for (FDir edir : endDirs) {
FGridCoord from = new FGridCoord(edge.from.gridCoord.x + sdir.x, edge.from.gridCoord.y + sdir.y);
FGridCoord to = new FGridCoord(edge.to.gridCoord.x + edir.x, edge.to.gridCoord.y + edir.y);
List<FGridCoord> path = getFlowPath(graph.grid, from, to);
if (path == null) continue;
List<FGridCoord> merged = mergeFlowPath(path);
String key = sdir.x + ":" + sdir.y + "|" + edir.x + ":" + edir.y + "|" + flowPathKey(merged);
if (!seen.add(key)) continue;
int penalty = overlapPenalty(graph, edge, merged, sdir);
PathCandidate c = new PathCandidate(penalty, merged.size(), bendCount(merged), sdir, edir, merged);
if (merged.size() >= 2) candidates.add(c);
else fallbackCandidates.add(c);
}
}
if (candidates.isEmpty()) {
if (!fallbackCandidates.isEmpty()) {
fallbackCandidates.sort(Comparator.comparingInt((PathCandidate c) -> c.penalty).thenComparingInt(c -> c.length));
PathCandidate best = fallbackCandidates.get(0);
List<FGridCoord> finalPath = best.path;
if (finalPath.size() == 1) {
FGridCoord p0 = finalPath.get(0);
List<FDir> doglegDirs = uniqueDirs(Arrays.asList(best.startDir, best.endDir, F_DOWN, F_RIGHT, F_LEFT, F_UP));
for (FDir d : doglegDirs) {
FGridCoord n = new FGridCoord(p0.x + d.x, p0.y + d.y);
if (n.x < 0 || n.y < 0) continue;
if (isFreeInGrid(graph.grid, n)) {
finalPath = new ArrayList<>();
finalPath.add(p0);
finalPath.add(n);
finalPath.add(p0);
break;
}
}
}
edge.startDir = best.startDir;
edge.endDir = best.endDir;
edge.path = finalPath;
return;
}
edge.startDir = dirs.altDir;
edge.endDir = dirs.altOpp;
edge.path = new ArrayList<>();
return;
}
candidates.sort(
Comparator.comparingInt((PathCandidate c) -> c.penalty)
.thenComparingInt(c -> c.bends)
.thenComparingInt(c -> c.length)
);
PathCandidate best = candidates.get(0);
edge.startDir = best.startDir;
edge.endDir = best.endDir;
edge.path = best.path;
}
private static List<FDir> fanoutStartDirs(FlowAsciiGraph graph, FEdge edge, FDir prefDir, FDir altDir, boolean fromPseudo) {
List<FEdge> outgoing = new ArrayList<>();
for (FEdge e : graph.edges) if (e.from == edge.from && e.to.gridCoord != null) outgoing.add(e);
if (fromPseudo || outgoing.size() <= 1) {
return uniqueDirs(Arrays.asList(prefDir, altDir, F_DOWN, F_RIGHT, F_LEFT, F_UP));
}
if ("TD".equals(graph.config.graphDirection)) {
outgoing.sort(Comparator.comparingInt((FEdge e) -> e.to.gridCoord.x).thenComparingInt(e -> e.to.gridCoord.y));
int idx = outgoing.indexOf(edge);
List<FDir> fanout = outgoing.size() == 2 ? Arrays.asList(F_DOWN, F_RIGHT) : Arrays.asList(F_DOWN, F_LEFT, F_RIGHT);
int pick = Math.min(Math.max(idx, 0), fanout.size() - 1);
FDir primary = fanout.get(pick);
return uniqueDirs(Arrays.asList(primary, prefDir, altDir, F_DOWN, F_LEFT, F_RIGHT, F_UP));
}
outgoing.sort(Comparator.comparingInt((FEdge e) -> e.to.gridCoord.y).thenComparingInt(e -> e.to.gridCoord.x));
int idx = outgoing.indexOf(edge);
List<FDir> fanout = outgoing.size() == 2 ? Arrays.asList(F_UP, F_DOWN) : Arrays.asList(F_UP, F_RIGHT, F_DOWN);
int pick = Math.min(Math.max(idx, 0), fanout.size() - 1);
FDir primary = fanout.get(pick);
return uniqueDirs(Arrays.asList(primary, prefDir, altDir, F_RIGHT, F_UP, F_DOWN, F_LEFT));
}
private static List<FDir> faninEndDirs(FlowAsciiGraph graph, FEdge edge, FDir prefOpp, FDir altOpp, boolean toPseudo) {
List<FEdge> incoming = new ArrayList<>();
for (FEdge e : graph.edges) if (e.to == edge.to && e.from.gridCoord != null) incoming.add(e);
if (toPseudo || incoming.size() <= 1) {
return uniqueDirs(Arrays.asList(prefOpp, altOpp, F_UP, F_LEFT, F_RIGHT, F_DOWN));
}
if ("TD".equals(graph.config.graphDirection)) {
incoming.sort(Comparator.comparingInt((FEdge e) -> e.from.gridCoord.x).thenComparingInt(e -> e.from.gridCoord.y));
int idx = incoming.indexOf(edge);
List<FDir> fanin = incoming.size() == 2 ? Arrays.asList(F_LEFT, F_RIGHT) : Arrays.asList(F_LEFT, F_UP, F_RIGHT);
int pick = Math.min(Math.max(idx, 0), fanin.size() - 1);
FDir primary = fanin.get(pick);
return uniqueDirs(Arrays.asList(primary, prefOpp, altOpp, F_UP, F_LEFT, F_RIGHT, F_DOWN));
}
incoming.sort(Comparator.comparingInt((FEdge e) -> e.from.gridCoord.y).thenComparingInt(e -> e.from.gridCoord.x));
int idx = incoming.indexOf(edge);
List<FDir> fanin = incoming.size() == 2 ? Arrays.asList(F_UP, F_DOWN) : Arrays.asList(F_UP, F_LEFT, F_DOWN);
int pick = Math.min(Math.max(idx, 0), fanin.size() - 1);
FDir primary = fanin.get(pick);
return uniqueDirs(Arrays.asList(primary, prefOpp, altOpp, F_LEFT, F_UP, F_DOWN, F_RIGHT));
}
private static List<FDir> uniqueDirs(List<FDir> dirs) {
List<FDir> out = new ArrayList<>();
for (FDir d : dirs) {
boolean exists = false;
for (FDir e : out) {
if (dirEq(d, e)) {
exists = true;
break;
}
}
if (!exists) out.add(d);
}
return out;
}
private static int overlapPenalty(FlowAsciiGraph graph, FEdge edge, List<FGridCoord> candidate, FDir sdir) {
Set<Long> me = pathKeys(candidate);
if (me.isEmpty()) return 0;
int penalty = 0;
if ("TD".equals(graph.config.graphDirection)) {
int dx = edge.to.gridCoord.x - edge.from.gridCoord.x;
if (dx > 0 && dirEq(sdir, F_LEFT)) penalty += 50;
else if (dx < 0 && dirEq(sdir, F_RIGHT)) penalty += 50;
else if (dx == 0 && !dirEq(sdir, F_DOWN)) penalty += 10;
} else {
int dy = edge.to.gridCoord.y - edge.from.gridCoord.y;
if (dy > 0 && dirEq(sdir, F_UP)) penalty += 50;
else if (dy < 0 && dirEq(sdir, F_DOWN)) penalty += 50;
else if (dy == 0 && !dirEq(sdir, F_RIGHT)) penalty += 10;
}
for (FEdge other : graph.edges) {
if (other == edge || other.path == null || other.path.isEmpty()) continue;
Set<Long> inter = new HashSet<>(me);
inter.retainAll(pathKeys(other.path));
if (!inter.isEmpty()) penalty += 100 * inter.size();
if (other.from == edge.from && dirEq(other.startDir, sdir)) penalty += 20;
if (other.from == edge.from && candidate.size() > 2 && other.path.size() > 2) {
Set<Long> minear = new HashSet<>();
Set<Long> otherear = new HashSet<>();
for (int i = 0; i < Math.min(3, candidate.size()); i++) minear.add(pack(candidate.get(i).x, candidate.get(i).y));
for (int i = 0; i < Math.min(3, other.path.size()); i++) otherear.add(pack(other.path.get(i).x, other.path.get(i).y));
minear.retainAll(otherear);
if (!minear.isEmpty()) penalty += 60 * minear.size();
}
}
return penalty;
}
private static Set<Long> pathKeys(List<FGridCoord> path) {
Set<Long> out = new HashSet<>();
if (path.size() <= 2) return out;
for (int i = 1; i < path.size() - 1; i++) out.add(pack(path.get(i).x, path.get(i).y));
return out;
}
private static int bendCount(List<FGridCoord> path) {
if (path.size() < 3) return 0;
int bends = 0;
FDir prev = determineDirection(path.get(0).x, path.get(0).y, path.get(1).x, path.get(1).y);
for (int i = 2; i < path.size(); i++) {
FDir cur = determineDirection(path.get(i - 1).x, path.get(i - 1).y, path.get(i).x, path.get(i).y);
if (!dirEq(cur, prev)) bends++;
prev = cur;
}
return bends;
}
private static String flowPathKey(List<FGridCoord> path) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < path.size(); i++) {
if (i > 0) sb.append(',');
sb.append(path.get(i).x).append(':').append(path.get(i).y);
}
return sb.toString();
}
private static FDirSet determineStartAndEndDir(FEdge edge, String graphDirection) {
if (edge.from == edge.to) return selfReferenceDirections(graphDirection);
FDir d = determineDirection(edge.from.gridCoord.x, edge.from.gridCoord.y, edge.to.gridCoord.x, edge.to.gridCoord.y);
boolean isBackwards = ("LR".equals(graphDirection) && (dirEq(d, F_LEFT) || dirEq(d, F_UPPER_LEFT) || dirEq(d, F_LOWER_LEFT)))
|| ("TD".equals(graphDirection) && (dirEq(d, F_UP) || dirEq(d, F_UPPER_LEFT) || dirEq(d, F_UPPER_RIGHT)));
FDir prefDir, prefOpp, altDir, altOpp;
if (dirEq(d, F_LOWER_RIGHT)) {
if ("LR".equals(graphDirection)) {
prefDir = F_DOWN; prefOpp = F_LEFT; altDir = F_RIGHT; altOpp = F_UP;
} else {
prefDir = F_RIGHT; prefOpp = F_UP; altDir = F_DOWN; altOpp = F_LEFT;
}
} else if (dirEq(d, F_UPPER_RIGHT)) {
if ("LR".equals(graphDirection)) {
prefDir = F_UP; prefOpp = F_LEFT; altDir = F_RIGHT; altOpp = F_DOWN;
} else {
prefDir = F_RIGHT; prefOpp = F_DOWN; altDir = F_UP; altOpp = F_LEFT;
}
} else if (dirEq(d, F_LOWER_LEFT)) {
if ("LR".equals(graphDirection)) {
prefDir = F_DOWN; prefOpp = F_DOWN; altDir = F_LEFT; altOpp = F_UP;
} else {
prefDir = F_LEFT; prefOpp = F_UP; altDir = F_DOWN; altOpp = F_RIGHT;
}
} else if (dirEq(d, F_UPPER_LEFT)) {
if ("LR".equals(graphDirection)) {
prefDir = F_DOWN; prefOpp = F_DOWN; altDir = F_LEFT; altOpp = F_DOWN;
} else {
prefDir = F_RIGHT; prefOpp = F_RIGHT; altDir = F_UP; altOpp = F_RIGHT;
}
} else if (isBackwards) {
if ("LR".equals(graphDirection) && dirEq(d, F_LEFT)) {
prefDir = F_DOWN; prefOpp = F_DOWN; altDir = F_LEFT; altOpp = F_RIGHT;
} else if ("TD".equals(graphDirection) && dirEq(d, F_UP)) {
prefDir = F_RIGHT; prefOpp = F_RIGHT; altDir = F_UP; altOpp = F_DOWN;
} else {
prefDir = d; prefOpp = oppositeDir(d); altDir = d; altOpp = oppositeDir(d);
}
} else {
prefDir = d; prefOpp = oppositeDir(d); altDir = d; altOpp = oppositeDir(d);
}
return new FDirSet(prefDir, prefOpp, altDir, altOpp);
}
private static FDirSet selfReferenceDirections(String graphDirection) {
if ("LR".equals(graphDirection)) return new FDirSet(F_RIGHT, F_DOWN, F_DOWN, F_RIGHT);
return new FDirSet(F_DOWN, F_RIGHT, F_RIGHT, F_DOWN);
}
private static FDir oppositeDir(FDir d) {
if (dirEq(d, F_UP)) return F_DOWN;
if (dirEq(d, F_DOWN)) return F_UP;
if (dirEq(d, F_LEFT)) return F_RIGHT;
if (dirEq(d, F_RIGHT)) return F_LEFT;
if (dirEq(d, F_UPPER_RIGHT)) return F_LOWER_LEFT;
if (dirEq(d, F_UPPER_LEFT)) return F_LOWER_RIGHT;
if (dirEq(d, F_LOWER_RIGHT)) return F_UPPER_LEFT;
if (dirEq(d, F_LOWER_LEFT)) return F_UPPER_RIGHT;
return F_MIDDLE;
}
private static boolean dirEq(FDir a, FDir b) { return a.x == b.x && a.y == b.y; }
private static FDir determineDirection(int fx, int fy, int tx, int ty) {
if (fx == tx) return fy < ty ? F_DOWN : F_UP;
if (fy == ty) return fx < tx ? F_RIGHT : F_LEFT;
if (fx < tx) return fy < ty ? F_LOWER_RIGHT : F_UPPER_RIGHT;
return fy < ty ? F_LOWER_LEFT : F_UPPER_LEFT;
}
private static List<FGridCoord> getFlowPath(Set<Long> grid, FGridCoord from, FGridCoord to) {
int dist = Math.abs(from.x - to.x) + Math.abs(from.y - to.y);
int margin = Math.max(12, dist * 2);
int minX = Math.max(0, Math.min(from.x, to.x) - margin);
int maxX = Math.max(from.x, to.x) + margin;
int minY = Math.max(0, Math.min(from.y, to.y) - margin);
int maxY = Math.max(from.y, to.y) + margin;
int maxVisited = 30_000;
PriorityQueue<FPathQ> pq = new PriorityQueue<>(Comparator.comparingInt((FPathQ a) -> a.priority));
Map<Long, Integer> cost = new HashMap<>();
Map<Long, Long> came = new HashMap<>();
long start = pack(from.x, from.y);
long goal = pack(to.x, to.y);
long seq = 0;
pq.add(new FPathQ(0, seq++, from.x, from.y));
cost.put(start, 0);
came.put(start, start);
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
int visited = 0;
while (!pq.isEmpty()) {
visited++;
if (visited > maxVisited) return null;
FPathQ cur = pq.poll();
long curId = pack(cur.x, cur.y);
if (curId == goal) {
ArrayDeque<FGridCoord> rev = new ArrayDeque<>();
long at = curId;
while (true) {
int x = (int) (at >> 32);
int y = (int) at;
rev.addFirst(new FGridCoord(x, y));
long prev = came.getOrDefault(at, at);
if (at == start) break;
at = prev;
}
return new ArrayList<>(rev);
}
int curCost = cost.getOrDefault(curId, Integer.MAX_VALUE / 4);
for (int[] d : dirs) {
int nx = cur.x + d[0];
int ny = cur.y + d[1];
if (nx < minX || nx > maxX || ny < minY || ny > maxY) continue;
long nid = pack(nx, ny);
if (nid != goal && !isFreeInGrid(grid, nx, ny)) continue;
int newCost = curCost + 1;
Integer ex = cost.get(nid);
if (ex == null || newCost < ex) {
cost.put(nid, newCost);
int h = heuristic(nx, ny, to.x, to.y);
pq.add(new FPathQ(newCost + h, seq++, nx, ny));
came.put(nid, curId);
}
}
}
return null;
}
private static boolean isFreeInGrid(Set<Long> grid, int x, int y) {
if (x < 0 || y < 0) return false;
return !grid.contains(pack(x, y));
}
private static boolean isFreeInGrid(Set<Long> grid, FGridCoord c) {
return isFreeInGrid(grid, c.x, c.y);
}
private static int heuristic(int ax, int ay, int bx, int by) {
int absX = Math.abs(ax - bx);
int absY = Math.abs(ay - by);
if (absX == 0 || absY == 0) return absX + absY;
return absX + absY + 1;
}
private static List<FGridCoord> mergeFlowPath(List<FGridCoord> path) {
if (path.size() <= 2) return path;
Set<Integer> remove = new HashSet<>();
FGridCoord a = path.get(0), b = path.get(1);
for (int i = 2; i < path.size(); i++) {
FGridCoord c = path.get(i);
int pdx = b.x - a.x, pdy = b.y - a.y;
int dx = c.x - b.x, dy = c.y - b.y;
if (pdx == dx && pdy == dy) remove.add(i - 1);
a = b;
b = c;
}
List<FGridCoord> out = new ArrayList<>();
for (int i = 0; i < path.size(); i++) if (!remove.contains(i)) out.add(path.get(i));
return out;
}
private static void determineFlowLabelLine(FlowAsciiGraph graph, FEdge edge) {
if (edge.text == null || edge.text.isEmpty() || edge.path.size() < 2) return;
int labelLen = edge.text.length();
FGridCoord prev = edge.path.get(0);
FGridCoord l0 = prev;
FGridCoord l1 = edge.path.get(1);
int largest = 0;
for (int i = 1; i < edge.path.size(); i++) {
FGridCoord step = edge.path.get(i);
int width = calculateFlowLineWidth(graph, prev, step);
if (width >= labelLen) {
l0 = prev; l1 = step; break;
} else if (width > largest) {
largest = width; l0 = prev; l1 = step;
}
prev = step;
}
int minX = Math.min(l0.x, l1.x);
int maxX = Math.max(l0.x, l1.x);
int middleX = minX + (maxX - minX) / 2;
graph.columnWidth.put(middleX, Math.max(graph.columnWidth.getOrDefault(middleX, 0), labelLen + 2));
edge.labelLine = Arrays.asList(l0, l1);
}
private static int calculateFlowLineWidth(FlowAsciiGraph graph, FGridCoord a, FGridCoord b) {
int total = 0;
int start = Math.min(a.x, b.x);
int end = Math.max(a.x, b.x);
for (int x = start; x <= end; x++) total += graph.columnWidth.getOrDefault(x, 0);
return total;
}
private static FDrawCoord gridToDrawingCoord(FlowAsciiGraph graph, FGridCoord c, FDir d) {
FGridCoord target = d == null ? c : new FGridCoord(c.x + d.x, c.y + d.y);
int x = 0;
for (int col = 0; col < target.x; col++) x += graph.columnWidth.getOrDefault(col, 0);
int y = 0;
for (int row = 0; row < target.y; row++) y += graph.rowHeight.getOrDefault(row, 0);
int colW = graph.columnWidth.getOrDefault(target.x, 0);
int rowH = graph.rowHeight.getOrDefault(target.y, 0);
return new FDrawCoord(x + (colW / 2) + graph.offsetX, y + (rowH / 2) + graph.offsetY);
}
private static FCanvasSize drawFlowBox(FlowAsciiGraph graph, FNode node, boolean draw) {
if (isPseudoStateNode(node)) {
if (draw && node.drawingCoord != null) {
graph.canvas.put(node.drawingCoord.x, node.drawingCoord.y, graph.config.useAscii ? '*' : '●');
}
return new FCanvasSize(0, 0);
}
int gcx = node.gridCoord.x;
int gcy = node.gridCoord.y;
int w = graph.columnWidth.getOrDefault(gcx, 0) + graph.columnWidth.getOrDefault(gcx + 1, 0);
int h = graph.rowHeight.getOrDefault(gcy, 0) + graph.rowHeight.getOrDefault(gcy + 1, 0);
if (draw) {
drawRectAt(graph.canvas, node.drawingCoord.x, node.drawingCoord.y, w, h, graph.config.useAscii);
int textY = node.drawingCoord.y + (h / 2);
int textX = node.drawingCoord.x + (w / 2) - ((node.displayLabel.length() + 1) / 2) + 1;
graph.canvas.putText(textX, textY, node.displayLabel);
}
return new FCanvasSize(w, h);
}
private static void drawRectAt(Canvas c, int x, int y, int w, int h, boolean ascii) {
char H = ascii ? '-' : '─';
char V = ascii ? '|' : '│';
char TL = ascii ? '+' : '┌';
char TR = ascii ? '+' : '┐';
char BL = ascii ? '+' : '└';
char BR = ascii ? '+' : '┘';
c.put(x, y, TL);
for (int i = 1; i < w; i++) c.put(x + i, y, H);
c.put(x + w, y, TR);
c.put(x, y + h, BL);
for (int i = 1; i < w; i++) c.put(x + i, y + h, H);
c.put(x + w, y + h, BR);
for (int j = 1; j < h; j++) {
c.put(x, y + j, V);
c.put(x + w, y + j, V);
}
}
private static void calculateSubgraphBoundingBoxes(FlowAsciiGraph graph) {
for (FSubgraph sg : graph.subgraphs) calculateSubgraphBoundingBox(graph, sg);
ensureSubgraphSpacing(graph);
}
private static void calculateSubgraphBoundingBox(FlowAsciiGraph graph, FSubgraph sg) {
if (sg.nodes.isEmpty()) return;
int minX = 1_000_000, minY = 1_000_000, maxX = -1_000_000, maxY = -1_000_000;
for (FSubgraph child : sg.children) {
calculateSubgraphBoundingBox(graph, child);
if (!child.nodes.isEmpty()) {
minX = Math.min(minX, child.minX);
minY = Math.min(minY, child.minY);
maxX = Math.max(maxX, child.maxX);
maxY = Math.max(maxY, child.maxY);
}
}
for (FNode node : sg.nodes) {
if (isPseudoStateNode(node)) continue;
if (node.drawingCoord == null) continue;
int nMinX = node.drawingCoord.x;
int nMinY = node.drawingCoord.y;
int nMaxX = nMinX + node.drawW;
int nMaxY = nMinY + node.drawH;
minX = Math.min(minX, nMinX);
minY = Math.min(minY, nMinY);
maxX = Math.max(maxX, nMaxX);
maxY = Math.max(maxY, nMaxY);
}
if (minX == 1_000_000 || minY == 1_000_000 || maxX == -1_000_000 || maxY == -1_000_000) return;
int subPadding = 1;
int labelSpace = 1;
sg.minX = minX - subPadding;
sg.minY = minY - subPadding - labelSpace;
sg.maxX = maxX + subPadding;
sg.maxY = maxY + subPadding;
}
private static void ensureSubgraphSpacing(FlowAsciiGraph graph) {
int minSpacing = 1;
List<FSubgraph> roots = new ArrayList<>();
for (FSubgraph sg : graph.subgraphs) if (sg.parent == null && !sg.nodes.isEmpty()) roots.add(sg);
for (int i = 0; i < roots.size(); i++) {
for (int j = i + 1; j < roots.size(); j++) {
FSubgraph a = roots.get(i), b = roots.get(j);
if (a.minX < b.maxX && a.maxX > b.minX) {
if (a.maxY >= b.minY - minSpacing && a.minY < b.minY) b.minY = a.maxY + minSpacing + 1;
else if (b.maxY >= a.minY - minSpacing && b.minY < a.minY) a.minY = b.maxY + minSpacing + 1;
}
if (a.minY < b.maxY && a.maxY > b.minY) {
if (a.maxX >= b.minX - minSpacing && a.minX < b.minX) b.minX = a.maxX + minSpacing + 1;
else if (b.maxX >= a.minX - minSpacing && b.minX < a.minX) a.minX = b.maxX + minSpacing + 1;
}
}
}
}
private static void offsetDrawingForSubgraphs(FlowAsciiGraph graph) {
if (graph.subgraphs.isEmpty()) return;
int minX = 0, minY = 0;
for (FSubgraph sg : graph.subgraphs) {
minX = Math.min(minX, sg.minX);
minY = Math.min(minY, sg.minY);
}
int offX = -minX, offY = -minY;
if (offX == 0 && offY == 0) return;
graph.offsetX = offX;
graph.offsetY = offY;
for (FSubgraph sg : graph.subgraphs) {
sg.minX += offX; sg.maxX += offX; sg.minY += offY; sg.maxY += offY;
}
for (FNode node : graph.nodes) {
if (node.drawingCoord != null) node.drawingCoord = new FDrawCoord(node.drawingCoord.x + offX, node.drawingCoord.y + offY);
}
}
private static void drawFlowGraph(FlowAsciiGraph graph) {
graph.canvas = new Canvas(1, 1);
graph.canvas.ensure(graph.canvasMaxX, graph.canvasMaxY);
List<FSubgraph> ordered = new ArrayList<>(graph.subgraphs);
ordered.sort(Comparator.comparingInt(Mermaid2ASCIITool::subgraphDepth));
for (FSubgraph sg : ordered) drawSubgraphBox(graph.canvas, sg, graph.config.useAscii);
for (FNode node : graph.nodes) {
if (!node.drawn && node.drawingCoord != null) {
drawFlowBox(graph, node, true);
node.drawn = true;
}
}
List<EdgeDrawData> draws = new ArrayList<>();
for (FEdge edge : graph.edges) {
if (edge.path == null || edge.path.isEmpty()) continue;
DrawPathResult dpr = drawFlowPath(graph, edge.path, edge);
boolean suppressBoxStart = isPseudoStateNode(edge.from) || getFlowEdgesFromNode(graph, edge.from).size() > 1;
draws.add(new EdgeDrawData(edge, dpr, suppressBoxStart));
}
for (EdgeDrawData d : draws) drawFlowCorners(graph.canvas, graph, d.edge.path);
for (EdgeDrawData d : draws) {
PendingArrow a = buildFlowArrowHead(graph.config.useAscii, d.path.lastSegment, d.path.lastDir, d.edge.endDir);
if (a != null) graph.canvas.put(a.x, a.y, a.ch);
}
for (EdgeDrawData d : draws) {
if (!d.suppressBoxStart) drawFlowBoxStart(graph.canvas, graph.config.useAscii, d.edge.path, d.path.firstSegment);
}
for (EdgeDrawData d : draws) {
PendingLabel l = buildFlowArrowLabel(graph, d.edge);
if (l != null) graph.canvas.putText(l.x, l.y, l.text);
}
for (FSubgraph sg : graph.subgraphs) drawSubgraphLabel(graph.canvas, sg);
}
private static void drawSubgraphBox(Canvas canvas, FSubgraph sg, boolean useAscii) {
int width = sg.maxX - sg.minX;
int height = sg.maxY - sg.minY;
if (width <= 0 || height <= 0) return;
drawRectAt(canvas, sg.minX, sg.minY, width, height, useAscii);
}
private static void drawSubgraphLabel(Canvas canvas, FSubgraph sg) {
int width = sg.maxX - sg.minX;
int height = sg.maxY - sg.minY;
if (width <= 0 || height <= 0) return;
int labelY = sg.minY + 1;
int labelX = sg.minX + (width / 2) - (sg.name.length() / 2);
if (labelX < sg.minX + 1) labelX = sg.minX + 1;
for (int i = 0; i < sg.name.length(); i++) {
int x = labelX + i;
if (x < sg.maxX) {
char ch = sg.name.charAt(i);
if (ch != ' ') canvas.put(x, labelY, ch);
}
}
}
private static DrawPathResult drawFlowPath(FlowAsciiGraph graph, List<FGridCoord> path, FEdge edge) {
List<FDrawCoord> firstSeg = new ArrayList<>();
List<FDrawCoord> lastSeg = new ArrayList<>();
FDir lastDir = F_MIDDLE;
for (int i = 1; i < path.size(); i++) {
FGridCoord prevGc = path.get(i - 1);
FGridCoord next = path.get(i);
FDrawCoord prevDc = gridToDrawingCoord(graph, prevGc, null);
FDrawCoord nextDc = gridToDrawingCoord(graph, next, null);
if (prevDc.x == nextDc.x && prevDc.y == nextDc.y) continue;
FDir dir = determineDirection(prevGc.x, prevGc.y, next.x, next.y);
boolean isFirst = i == 1;
boolean isLast = i == path.size() - 1;
if (isFirst) {
FNode node = getNodeAtCoord(graph, prevGc);
if (node != null && node.drawingCoord != null) prevDc = borderCoord(node, dir, prevDc);
}
if (isLast) {
FNode node = getNodeAtCoord(graph, next);
if (node != null && node.drawingCoord != null) nextDc = borderCoord(node, oppositeDir(dir), nextDc);
}
int offsetFrom = isFirst ? 0 : 1;
int offsetTo = isLast ? 0 : -1;
List<FDrawCoord> seg = drawFlowLine(graph.canvas, prevDc, nextDc, offsetFrom, offsetTo, graph.config.useAscii);
if (seg.isEmpty()) seg.add(prevDc);
if (firstSeg.isEmpty()) firstSeg = seg;
lastSeg = seg;
lastDir = dir;
}
if (lastSeg.isEmpty() && !path.isEmpty()) {
FDrawCoord single = gridToDrawingCoord(graph, path.get(path.size() - 1), null);
lastSeg = new ArrayList<>(List.of(single));
if (firstSeg.isEmpty()) firstSeg = lastSeg;
}
return new DrawPathResult(firstSeg, lastSeg, lastDir);
}
private static FNode getNodeAtCoord(FlowAsciiGraph graph, FGridCoord coord) {
return graph.gridOwner.get(pack(coord.x, coord.y));
}
private static FDrawCoord borderCoord(FNode node, FDir side, FDrawCoord lane) {
int left = node.drawingCoord.x;
int top = node.drawingCoord.y;
int width = node.drawW + 1;
int height = node.drawH + 1;
int cx = left + width / 2;
int cy = top + height / 2;
if (dirEq(side, F_LEFT)) return new FDrawCoord(left, lane.y);
if (dirEq(side, F_RIGHT)) return new FDrawCoord(left + width - 1, lane.y);
if (dirEq(side, F_UP)) return new FDrawCoord(lane.x, top);
if (dirEq(side, F_DOWN)) return new FDrawCoord(lane.x, top + height - 1);
return new FDrawCoord(cx, cy);
}
private static List<FDrawCoord> drawFlowLine(Canvas canvas, FDrawCoord from, FDrawCoord to, int offsetFrom, int offsetTo, boolean useAscii) {
FDir dir = determineDirection(from.x, from.y, to.x, to.y);
List<FDrawCoord> drawn = new ArrayList<>();
char h = useAscii ? '-' : '─';
char v = useAscii ? '|' : '│';
char bslash = useAscii ? '\\' : '╲';
char fslash = useAscii ? '/' : '';
if (dirEq(dir, F_UP)) {
for (int y = from.y - offsetFrom; y >= to.y - offsetTo; y--) { drawn.add(new FDrawCoord(from.x, y)); putFlowLineChar(canvas, from.x, y, v, useAscii); }
} else if (dirEq(dir, F_DOWN)) {
for (int y = from.y + offsetFrom; y <= to.y + offsetTo; y++) { drawn.add(new FDrawCoord(from.x, y)); putFlowLineChar(canvas, from.x, y, v, useAscii); }
} else if (dirEq(dir, F_LEFT)) {
for (int x = from.x - offsetFrom; x >= to.x - offsetTo; x--) { drawn.add(new FDrawCoord(x, from.y)); putFlowLineChar(canvas, x, from.y, h, useAscii); }
} else if (dirEq(dir, F_RIGHT)) {
for (int x = from.x + offsetFrom; x <= to.x + offsetTo; x++) { drawn.add(new FDrawCoord(x, from.y)); putFlowLineChar(canvas, x, from.y, h, useAscii); }
} else if (dirEq(dir, F_UPPER_LEFT)) {
int x = from.x, y = from.y - offsetFrom;
while (x >= to.x - offsetTo && y >= to.y - offsetTo) { drawn.add(new FDrawCoord(x, y)); putFlowLineChar(canvas, x, y, bslash, useAscii); x--; y--; }
} else if (dirEq(dir, F_UPPER_RIGHT)) {
int x = from.x, y = from.y - offsetFrom;
while (x <= to.x + offsetTo && y >= to.y - offsetTo) { drawn.add(new FDrawCoord(x, y)); putFlowLineChar(canvas, x, y, fslash, useAscii); x++; y--; }
} else if (dirEq(dir, F_LOWER_LEFT)) {
int x = from.x, y = from.y + offsetFrom;
while (x >= to.x - offsetTo && y <= to.y + offsetTo) { drawn.add(new FDrawCoord(x, y)); putFlowLineChar(canvas, x, y, fslash, useAscii); x--; y++; }
} else if (dirEq(dir, F_LOWER_RIGHT)) {
int x = from.x, y = from.y + offsetFrom;
while (x <= to.x + offsetTo && y <= to.y + offsetTo) { drawn.add(new FDrawCoord(x, y)); putFlowLineChar(canvas, x, y, bslash, useAscii); x++; y++; }
}
return drawn;
}
private static void putFlowLineChar(Canvas canvas, int x, int y, char incoming, boolean useAscii) {
char cur = canvas.get(x, y);
if (cur == ' ') {
canvas.put(x, y, incoming);
return;
}
if (!useAscii && isFlowJunction(cur) && isFlowJunction(incoming)) {
canvas.put(x, y, mergeFlowJunction(cur, incoming));
return;
}
canvas.put(x, y, incoming);
}
private static boolean isFlowJunction(char c) {
return c == '─' || c == '│' || c == '┌' || c == '┐' || c == '└' || c == '┘'
|| c == '├' || c == '┤' || c == '┬' || c == '┴' || c == '┼'
|| c == '╴' || c == '╵' || c == '╶' || c == '╷';
}
private static char mergeFlowJunction(char c1, char c2) {
if (c1 == '─') {
if (c2 == '│') return '┼'; if (c2 == '┌') return '┬'; if (c2 == '┐') return '┬';
if (c2 == '└') return '┴'; if (c2 == '┘') return '┴'; if (c2 == '├') return '┼';
if (c2 == '┤') return '┼'; if (c2 == '┬') return '┬'; if (c2 == '┴') return '┴';
return c1;
}
if (c1 == '│') {
if (c2 == '─') return '┼'; if (c2 == '┌') return '├'; if (c2 == '┐') return '┤';
if (c2 == '└') return '├'; if (c2 == '┘') return '┤'; if (c2 == '├') return '├';
if (c2 == '┤') return '┤'; if (c2 == '┬') return '┼'; if (c2 == '┴') return '┼';
return c1;
}
if (c1 == '┌') {
if (c2 == '─' || c2 == '┐' || c2 == '┬') return '┬';
if (c2 == '│' || c2 == '└' || c2 == '├') return '├';
if (c2 == '┘' || c2 == '┤' || c2 == '┴') return '┼';
return c1;
}
if (c1 == '┐') {
if (c2 == '─' || c2 == '┌' || c2 == '┬') return '┬';
if (c2 == '│' || c2 == '┘' || c2 == '┤') return '┤';
if (c2 == '└' || c2 == '├' || c2 == '┴') return '┼';
return c1;
}
if (c1 == '└') {
if (c2 == '─' || c2 == '┘' || c2 == '┴') return '┴';
if (c2 == '│' || c2 == '┌' || c2 == '├') return '├';
if (c2 == '┐' || c2 == '┤' || c2 == '┬') return '┼';
return c1;
}
if (c1 == '┘') {
if (c2 == '─' || c2 == '└' || c2 == '┴') return '┴';
if (c2 == '│' || c2 == '┐' || c2 == '┤') return '┤';
if (c2 == '┌' || c2 == '├' || c2 == '┬') return '┼';
return c1;
}
if (c1 == '├') {
if (c2 == '│' || c2 == '┌' || c2 == '└') return '├';
if (c2 == '─' || c2 == '┐' || c2 == '┘' || c2 == '┤' || c2 == '┬' || c2 == '┴') return '┼';
return c1;
}
if (c1 == '┤') {
if (c2 == '│' || c2 == '┐' || c2 == '┘') return '┤';
if (c2 == '─' || c2 == '┌' || c2 == '└' || c2 == '├' || c2 == '┬' || c2 == '┴') return '┼';
return c1;
}
if (c1 == '┬') {
if (c2 == '─' || c2 == '┌' || c2 == '┐') return '┬';
if (c2 == '│' || c2 == '└' || c2 == '┘' || c2 == '├' || c2 == '┤' || c2 == '┴') return '┼';
return c1;
}
if (c1 == '┴') {
if (c2 == '─' || c2 == '└' || c2 == '┘') return '┴';
if (c2 == '│' || c2 == '┌' || c2 == '┐' || c2 == '├' || c2 == '┤' || c2 == '┬') return '┼';
return c1;
}
return c1;
}
private static void drawFlowBoxStart(Canvas canvas, boolean useAscii, List<FGridCoord> path, List<FDrawCoord> firstLine) {
if (useAscii || path.size() < 2 || firstLine.isEmpty()) return;
FDrawCoord from = firstLine.get(0);
FDir dir = determineDirection(path.get(0).x, path.get(0).y, path.get(1).x, path.get(1).y);
if (dirEq(dir, F_UP)) putFlowLineChar(canvas, from.x, from.y, '┴', false);
else if (dirEq(dir, F_DOWN)) putFlowLineChar(canvas, from.x, from.y, '┬', false);
else if (dirEq(dir, F_LEFT)) putFlowLineChar(canvas, from.x, from.y, '┤', false);
else if (dirEq(dir, F_RIGHT)) putFlowLineChar(canvas, from.x, from.y, '├', false);
}
private static PendingArrow buildFlowArrowHead(boolean useAscii, List<FDrawCoord> lastLine, FDir lastDir, FDir fallbackDir) {
if (lastLine.isEmpty()) return null;
FDrawCoord from = lastLine.get(0);
FDrawCoord pos = lastLine.get(lastLine.size() - 1);
FDir dir = determineDirection(from.x, from.y, pos.x, pos.y);
if (lastLine.size() == 1 || dirEq(dir, F_MIDDLE)) dir = fallbackDir != null ? fallbackDir : lastDir;
char ch;
if (!useAscii) {
if (dirEq(dir, F_UP)) ch = '▲';
else if (dirEq(dir, F_DOWN)) ch = '▼';
else if (dirEq(dir, F_LEFT)) ch = '◄';
else if (dirEq(dir, F_RIGHT)) ch = '►';
else if (dirEq(dir, F_UPPER_RIGHT)) ch = '◥';
else if (dirEq(dir, F_UPPER_LEFT)) ch = '◤';
else if (dirEq(dir, F_LOWER_RIGHT)) ch = '◢';
else if (dirEq(dir, F_LOWER_LEFT)) ch = '◣';
else ch = '●';
} else {
if (dirEq(dir, F_UP)) ch = '^';
else if (dirEq(dir, F_DOWN)) ch = 'v';
else if (dirEq(dir, F_LEFT)) ch = '<';
else if (dirEq(dir, F_RIGHT)) ch = '>';
else ch = '*';
}
return new PendingArrow(pos.x, pos.y, ch);
}
private static void drawFlowCorners(Canvas canvas, FlowAsciiGraph graph, List<FGridCoord> path) {
for (int i = 1; i < path.size() - 1; i++) {
FGridCoord coord = path.get(i);
FDrawCoord dc = gridToDrawingCoord(graph, coord, null);
FDir prev = determineDirection(path.get(i - 1).x, path.get(i - 1).y, coord.x, coord.y);
FDir next = determineDirection(coord.x, coord.y, path.get(i + 1).x, path.get(i + 1).y);
char corner;
if (!graph.config.useAscii) {
if ((dirEq(prev, F_RIGHT) && dirEq(next, F_DOWN)) || (dirEq(prev, F_UP) && dirEq(next, F_LEFT))) corner = '┐';
else if ((dirEq(prev, F_RIGHT) && dirEq(next, F_UP)) || (dirEq(prev, F_DOWN) && dirEq(next, F_LEFT))) corner = '┘';
else if ((dirEq(prev, F_LEFT) && dirEq(next, F_DOWN)) || (dirEq(prev, F_UP) && dirEq(next, F_RIGHT))) corner = '┌';
else if ((dirEq(prev, F_LEFT) && dirEq(next, F_UP)) || (dirEq(prev, F_DOWN) && dirEq(next, F_RIGHT))) corner = '└';
else corner = '+';
} else corner = '+';
putFlowLineChar(canvas, dc.x, dc.y, corner, graph.config.useAscii);
}
}
private static PendingLabel buildFlowArrowLabel(FlowAsciiGraph graph, FEdge edge) {
if (edge.text == null || edge.text.isEmpty() || edge.labelLine == null || edge.labelLine.size() < 2) return null;
FDrawCoord a = gridToDrawingCoord(graph, edge.labelLine.get(0), null);
FDrawCoord b = gridToDrawingCoord(graph, edge.labelLine.get(1), null);
int minX = Math.min(a.x, b.x), maxX = Math.max(a.x, b.x);
int minY = Math.min(a.y, b.y), maxY = Math.max(a.y, b.y);
int midX = minX + (maxX - minX) / 2;
int midY = minY + (maxY - minY) / 2;
int startX = midX - (edge.text.length() / 2);
return new PendingLabel(startX, midY, edge.text);
}
private static void flipCanvasVertically(Canvas canvas, int maxX, int maxY) {
Map<Character, Character> flip = new HashMap<>();
flip.put('▲', '▼'); flip.put('▼', '▲');
flip.put('◤', '◣'); flip.put('◣', '◤');
flip.put('◥', '◢'); flip.put('◢', '◥');
flip.put('^', 'v'); flip.put('v', '^');
flip.put('┌', '└'); flip.put('└', '┌');
flip.put('┐', '┘'); flip.put('┘', '┐');
flip.put('┬', '┴'); flip.put('┴', '┬');
flip.put('╵', '╷'); flip.put('╷', '╵');
for (int y = 0; y <= maxY / 2; y++) {
int y2 = maxY - y;
for (int x = 0; x <= maxX; x++) {
char a = canvas.get(x, y);
char b = canvas.get(x, y2);
canvas.put(x, y, b);
canvas.put(x, y2, a);
}
}
for (int y = 0; y <= maxY; y++) {
for (int x = 0; x <= maxX; x++) {
char ch = canvas.get(x, y);
if (flip.containsKey(ch)) canvas.put(x, y, flip.get(ch));
}
}
}
private static String canvasToStringFull(Canvas canvas, int maxX, int maxY) {
int minX = maxX + 1;
int minY = maxY + 1;
int usedMaxX = -1;
int usedMaxY = -1;
for (int x = 0; x <= maxX; x++) {
for (int y = 0; y <= maxY; y++) {
if (canvas.get(x, y) != ' ') {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
usedMaxX = Math.max(usedMaxX, x);
usedMaxY = Math.max(usedMaxY, y);
}
}
}
if (usedMaxX < 0 || usedMaxY < 0) return "";
StringBuilder sb = new StringBuilder();
for (int y = minY; y <= usedMaxY; y++) {
int endX = usedMaxX;
while (endX >= minX && canvas.get(endX, y) == ' ') endX--;
for (int x = minX; x <= endX; x++) sb.append(canvas.get(x, y));
if (y < usedMaxY) sb.append('\n');
}
return sb.toString();
}
private static final FDir F_UP = new FDir(1, 0);
private static final FDir F_DOWN = new FDir(1, 2);
private static final FDir F_LEFT = new FDir(0, 1);
private static final FDir F_RIGHT = new FDir(2, 1);
private static final FDir F_UPPER_RIGHT = new FDir(2, 0);
private static final FDir F_UPPER_LEFT = new FDir(0, 0);
private static final FDir F_LOWER_RIGHT = new FDir(2, 2);
private static final FDir F_LOWER_LEFT = new FDir(0, 2);
private static final FDir F_MIDDLE = new FDir(1, 1);
private static void printUsage() {
System.err.println("Usage: java BeautifulMermaid <input.mmd> [--ascii] [--padding-x N] [--padding-y N] [--box-padding N]");
}
private static final class CliOptions {
final Path input;
final boolean useAscii;
final int paddingX;
final int paddingY;
final int boxPadding;
CliOptions(Path input, boolean useAscii, int paddingX, int paddingY, int boxPadding) {
this.input = input;
this.useAscii = useAscii;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.boxPadding = boxPadding;
}
static CliOptions parse(String[] args) {
if (args.length == 0) return null;
Path input = null;
boolean useAscii = false;
int paddingX = 6;
int paddingY = 4;
int boxPadding = 1;
for (int i = 0; i < args.length; i++) {
String cur = args[i];
if ("--ascii".equals(cur)) {
useAscii = true;
} else if ("--padding-x".equals(cur) || "--padding-y".equals(cur) || "--box-padding".equals(cur)) {
if (i + 1 >= args.length) return null;
int val;
try {
val = Integer.parseInt(args[++i]);
} catch (NumberFormatException e) {
return null;
}
if ("--padding-x".equals(cur)) paddingX = val;
else if ("--padding-y".equals(cur)) paddingY = val;
else boxPadding = val;
} else if (cur.startsWith("--")) {
return null;
} else {
if (input != null) return null;
input = Path.of(cur);
}
}
if (input == null) return null;
return new CliOptions(input, useAscii, paddingX, paddingY, boxPadding);
}
}
private static final class Canvas {
private final List<char[]> rows = new ArrayList<>();
private int w;
private int baseH;
Canvas(int width, int height) {
this.w = Math.max(4, width);
int h = Math.max(4, height);
this.baseH = h;
for (int i = 0; i < h; i++) {
char[] r = new char[this.w];
Arrays.fill(r, ' ');
rows.add(r);
}
}
void ensure(int x, int y) {
if (x < 0 || y < 0) return;
while (y >= rows.size()) {
char[] r = new char[w];
Arrays.fill(r, ' ');
rows.add(r);
}
if (x >= w) {
int nw = Math.max(x + 1, w * 2);
for (int i = 0; i < rows.size(); i++) {
char[] old = rows.get(i);
char[] nr = new char[nw];
Arrays.fill(nr, ' ');
System.arraycopy(old, 0, nr, 0, old.length);
rows.set(i, nr);
}
w = nw;
}
}
void put(int x, int y, char ch) {
if (x < 0 || y < 0) return;
ensure(x, y);
rows.get(y)[x] = ch;
}
char get(int x, int y) {
if (x < 0 || y < 0 || y >= rows.size() || x >= w) return ' ';
return rows.get(y)[x];
}
void putText(int x, int y, String s) {
if (s == null || s.isEmpty()) return;
for (int i = 0; i < s.length(); i++) put(x + i, y, s.charAt(i));
}
void hLine(int x1, int x2, int y, char ch) {
if (y < 0) return;
int a = Math.min(x1, x2);
int b = Math.max(x1, x2);
for (int x = a; x <= b; x++) put(x, y, ch);
}
void vLine(int y1, int y2, int x, char ch) {
if (x < 0) return;
int a = Math.min(y1, y2);
int b = Math.max(y1, y2);
for (int y = a; y <= b; y++) put(x, y, ch);
}
void drawRect(int x, int y, int w, int h, boolean ascii) {
if (w < 2 || h < 2) return;
char H = ascii ? '-' : '─';
char V = ascii ? '|' : '│';
char TL = ascii ? '+' : '┌';
char TR = ascii ? '+' : '┐';
char BL = ascii ? '+' : '└';
char BR = ascii ? '+' : '┘';
put(x, y, TL);
put(x + w, y, TR);
put(x, y + h, BL);
put(x + w, y + h, BR);
hLine(x + 1, x + w - 1, y, H);
hLine(x + 1, x + w - 1, y + h, H);
vLine(y + 1, y + h - 1, x, V);
vLine(y + 1, y + h - 1, x + w, V);
}
String render() {
int maxY = rows.size() - 1;
int maxX = 0;
for (char[] r : rows) {
for (int x = r.length - 1; x >= 0; x--) {
if (r[x] != ' ') {
maxX = Math.max(maxX, x);
break;
}
}
}
while (maxY >= 0) {
char[] r = rows.get(maxY);
boolean any = false;
for (int x = 0; x <= maxX; x++) if (x < r.length && r[x] != ' ') { any = true; break; }
if (any) break;
maxY--;
}
if (maxY < 0 || maxX == 0) return "";
StringBuilder sb = new StringBuilder();
for (int y = 0; y <= maxY; y++) {
char[] r = rows.get(y);
int right = maxX;
while (right >= 0 && (right >= r.length || r[right] == ' ')) right--;
if (right < 0) {
sb.append('\n');
continue;
}
sb.append(r, 0, right + 1).append('\n');
}
return sb.toString();
}
String renderFixed() {
StringBuilder sb = new StringBuilder();
int h = Math.max(baseH, rows.size());
for (int y = 0; y < h; y++) {
char[] r = y < rows.size() ? rows.get(y) : null;
if (r == null) {
for (int x = 0; x < w; x++) sb.append(' ');
} else if (r.length >= w) {
sb.append(r, 0, w);
} else {
sb.append(r, 0, r.length);
for (int x = r.length; x < w; x++) sb.append(' ');
}
sb.append('\n');
}
return sb.toString();
}
}
private static final class FlowGraph {
String direction;
final Map<String, FlowNode> nodes = new LinkedHashMap<>();
final List<FlowEdge> edges = new ArrayList<>();
final List<Subgraph> subgraphs = new ArrayList<>();
FlowGraph(String direction) { this.direction = direction; }
}
private static final class Point {
final int x;
final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
private static final class FlowNode {
final String id;
String label;
int x;
int y;
int w;
int h;
final Set<Subgraph> subgraphs = new HashSet<>();
FlowNode(String id, String label) {
this.id = id;
if ((id.startsWith("_start") || id.startsWith("_end")) && (label == null || label.isBlank())) {
this.label = "";
} else {
this.label = (label == null) ? id : label;
}
}
boolean isPseudo() {
return id.startsWith("_start") || id.startsWith("_end") || "START".equals(id) || "END".equals(id);
}
}
private static final class FlowEdge {
final String from;
final String to;
final String label;
final String op;
FlowEdge(String from, String to, String label, String op) {
this.from = from;
this.to = to;
this.label = label == null ? "" : label;
this.op = op == null ? "-->" : op;
}
}
private static final class FGridCoord {
final int x;
final int y;
FGridCoord(int x, int y) { this.x = x; this.y = y; }
}
private static final class FDrawCoord {
final int x;
final int y;
FDrawCoord(int x, int y) { this.x = x; this.y = y; }
}
private static final class FDir {
final int x;
final int y;
FDir(int x, int y) { this.x = x; this.y = y; }
}
private static final class FDirSet {
final FDir prefDir;
final FDir prefOpp;
final FDir altDir;
final FDir altOpp;
FDirSet(FDir prefDir, FDir prefOpp, FDir altDir, FDir altOpp) {
this.prefDir = prefDir; this.prefOpp = prefOpp; this.altDir = altDir; this.altOpp = altOpp;
}
}
private static final class FPathQ {
final int priority;
final long seq;
final int x;
final int y;
FPathQ(int priority, long seq, int x, int y) { this.priority = priority; this.seq = seq; this.x = x; this.y = y; }
}
private static final class FCanvasSize {
final int w;
final int h;
FCanvasSize(int w, int h) { this.w = w; this.h = h; }
}
private static final class PathCandidate {
final int penalty;
final int length;
final int bends;
final FDir startDir;
final FDir endDir;
final List<FGridCoord> path;
PathCandidate(int penalty, int length, int bends, FDir startDir, FDir endDir, List<FGridCoord> path) {
this.penalty = penalty;
this.length = length;
this.bends = bends;
this.startDir = startDir;
this.endDir = endDir;
this.path = path;
}
}
private static final class DrawPathResult {
final List<FDrawCoord> firstSegment;
final List<FDrawCoord> lastSegment;
final FDir lastDir;
DrawPathResult(List<FDrawCoord> firstSegment, List<FDrawCoord> lastSegment, FDir lastDir) {
this.firstSegment = firstSegment;
this.lastSegment = lastSegment;
this.lastDir = lastDir;
}
}
private static final class EdgeDrawData {
final FEdge edge;
final DrawPathResult path;
final boolean suppressBoxStart;
EdgeDrawData(FEdge edge, DrawPathResult path, boolean suppressBoxStart) {
this.edge = edge;
this.path = path;
this.suppressBoxStart = suppressBoxStart;
}
}
private static final class PendingArrow {
final int x;
final int y;
final char ch;
PendingArrow(int x, int y, char ch) { this.x = x; this.y = y; this.ch = ch; }
}
private static final class PendingLabel {
final int x;
final int y;
final String text;
PendingLabel(int x, int y, String text) { this.x = x; this.y = y; this.text = text; }
}
private static final class FNode {
final String name;
final String displayLabel;
final int index;
FGridCoord gridCoord;
FDrawCoord drawingCoord;
int drawW;
int drawH;
boolean drawn;
FNode(String name, String displayLabel, int index) {
this.name = name;
this.displayLabel = displayLabel == null ? "" : displayLabel;
this.index = index;
}
}
private static final class FEdge {
final FNode from;
final FNode to;
final String text;
List<FGridCoord> path = new ArrayList<>();
List<FGridCoord> labelLine = new ArrayList<>();
FDir startDir = F_MIDDLE;
FDir endDir = F_MIDDLE;
FEdge(FNode from, FNode to, String text) {
this.from = from; this.to = to; this.text = text == null ? "" : text;
}
}
private static final class FSubgraph {
final String name;
final List<FNode> nodes;
FSubgraph parent;
final List<FSubgraph> children;
String direction;
int minX;
int minY;
int maxX;
int maxY;
FSubgraph(String name, List<FNode> nodes, FSubgraph parent, List<FSubgraph> children, String direction) {
this.name = name == null ? "" : name;
this.nodes = nodes;
this.parent = parent;
this.children = children;
this.direction = direction;
}
}
private static final class FlowConfig {
final boolean useAscii;
final int paddingX;
final int paddingY;
final int boxBorderPadding;
final String graphDirection;
FlowConfig(boolean useAscii, int paddingX, int paddingY, int boxBorderPadding, String graphDirection) {
this.useAscii = useAscii;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.boxBorderPadding = boxBorderPadding;
this.graphDirection = graphDirection;
}
}
private static final class FlowAsciiGraph {
final List<FNode> nodes;
final List<FEdge> edges;
Canvas canvas;
final Set<Long> grid;
final Map<Long, FNode> gridOwner;
final Map<Integer, Integer> columnWidth;
final Map<Integer, Integer> rowHeight;
final List<FSubgraph> subgraphs;
final FlowConfig config;
int offsetX;
int offsetY;
int canvasMaxX;
int canvasMaxY;
final Map<String, FNode> nodeByName = new HashMap<>();
FlowAsciiGraph(List<FNode> nodes, List<FEdge> edges, Canvas canvas, Set<Long> grid,
Map<Integer, Integer> columnWidth, Map<Integer, Integer> rowHeight, Map<Long, FNode> gridOwner,
List<FSubgraph> subgraphs, FlowConfig config) {
this.nodes = nodes;
this.edges = edges;
this.canvas = canvas;
this.grid = grid;
this.gridOwner = gridOwner;
this.columnWidth = columnWidth;
this.rowHeight = rowHeight;
this.subgraphs = subgraphs;
this.config = config;
this.offsetX = 0;
this.offsetY = 0;
this.canvasMaxX = 0;
this.canvasMaxY = 0;
}
}
private static final class Subgraph {
final String id;
final String label;
final Subgraph parent;
String direction;
final List<Subgraph> children = new ArrayList<>();
final Set<String> nodeIds = new LinkedHashSet<>();
Subgraph(String id, String label, Subgraph parent, String direction) {
this.id = id;
this.label = label;
this.parent = parent;
this.direction = direction;
}
}
private static final class EdgeToken {
final String op;
final String label;
final String targetToken;
final String remaining;
EdgeToken(String op, String label, String targetToken, String remaining) {
this.op = op;
this.label = label == null ? "" : label;
this.targetToken = targetToken == null ? "" : targetToken;
this.remaining = remaining == null ? "" : remaining;
}
}
private static final class NodeConsume {
final String id;
final String remaining;
NodeConsume(String id, String remaining) {
this.id = id;
this.remaining = remaining == null ? "" : remaining;
}
}
private static final class SequenceDiagram {
final List<SeqActor> actors = new ArrayList<>();
final List<SeqMessage> messages = new ArrayList<>();
final List<Block> blocks = new ArrayList<>();
final List<SeqNote> notes = new ArrayList<>();
}
private static final class SeqActor {
final String id;
final String label;
final String type;
SeqActor(String id, String label, String type) {
this.id = id;
this.label = label;
this.type = type;
}
}
private static final class SeqMessage {
final String from;
final String to;
final String label;
final boolean dashed;
final boolean filled;
boolean activate;
boolean deactivate;
SeqMessage(String from, String to, String label, boolean dashed, boolean filled) {
this.from = from;
this.to = to;
this.label = label == null ? "" : label;
this.dashed = dashed;
this.filled = filled;
this.activate = false;
this.deactivate = false;
}
}
private static final class ParsedMessage {
final String from;
final String to;
final String label;
final boolean dashed;
final boolean filled;
ParsedMessage(String from, String to, String label, boolean dashed, boolean filled) {
this.from = from;
this.to = to;
this.label = label;
this.dashed = dashed;
this.filled = filled;
}
}
private static final class Block {
String type;
String label;
int startIndex;
int endIndex;
final List<BlockDivider> dividers = new ArrayList<>();
}
private static final class BlockDivider {
final int index;
final String label;
BlockDivider(int index, String label) {
this.index = index;
this.label = label;
}
}
private static final class BlockCtx {
final Block block;
BlockCtx(Block b) {
this.block = b;
}
}
private static final class SeqNote {
final List<String> actorIds;
final String text;
final String pos;
final int afterIndex;
SeqNote(List<String> actorIds, String text, String pos, int afterIndex) {
this.actorIds = actorIds;
this.text = text;
this.pos = pos;
this.afterIndex = afterIndex;
}
}
private static final class NotePos {
final int x;
final int y;
final int width;
final int height;
final List<String> lines;
NotePos(int x, int y, int width, int height, List<String> lines) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.lines = lines;
}
}
private static final class ClassDiagram {
final Map<String, ClassNode> classes = new LinkedHashMap<>();
final List<ClassRel> relationships = new ArrayList<>();
}
private static final class ClassNode {
final String id;
String label;
String annotation;
final List<String> attributes = new ArrayList<>();
final List<String> methods = new ArrayList<>();
ClassNode(String id, String label) {
this.id = id;
this.label = label;
}
}
private static final class ClassRel {
final String from;
final String to;
final String type;
final String markerAt;
final String label;
final String fromCardinality;
final String toCardinality;
ClassRel(String from, String to, String type, String markerAt, String label, String fromCardinality, String toCardinality) {
this.from = from;
this.to = to;
this.type = type;
this.markerAt = markerAt;
this.label = label;
this.fromCardinality = fromCardinality;
this.toCardinality = toCardinality;
}
}
private static final class ArrowParsed {
final String type;
final String markerAt;
ArrowParsed(String type, String markerAt) {
this.type = type;
this.markerAt = markerAt;
}
}
private static final class ParsedClassMember {
final String text;
final boolean isMethod;
final boolean isStatic;
final boolean isAbstract;
ParsedClassMember(String text, boolean isMethod, boolean isStatic, boolean isAbstract) {
this.text = text;
this.isMethod = isMethod;
this.isStatic = isStatic;
this.isAbstract = isAbstract;
}
}
private static final class ErDiagram {
final Map<String, ErEntity> entities = new LinkedHashMap<>();
final List<ErRel> relationships = new ArrayList<>();
}
private static final class ErEntity {
final String id;
final String label;
final List<String> attributes = new ArrayList<>();
ErEntity(String id, String label) {
this.id = id;
this.label = label;
}
}
private static final class ErRel {
final String e1;
final String e2;
final String c1;
final String c2;
final String label;
final boolean identifying;
ErRel(String e1, String e2, String c1, String c2, String label, boolean identifying) {
this.e1 = e1;
this.e2 = e2;
this.c1 = c1;
this.c2 = c2;
this.label = label;
this.identifying = identifying;
}
}
private static final class Box {
final int x;
final int y;
final int w;
final int h;
final List<String> lines;
Box(int x, int y, int w, int h, List<String> lines) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.lines = lines;
}
}
private static final class BoxSize {
final int w;
final int h;
BoxSize(int w, int h) {
this.w = w;
this.h = h;
}
}
}