diff options
| author | Michael Peter Christen <mc@yacy.net> | 2026-02-08 13:34:43 +0100 |
|---|---|---|
| committer | Michael Peter Christen <mc@yacy.net> | 2026-02-08 13:34:43 +0100 |
| commit | ccf42a19c0299da89df910445381a64b1fed1a95 (patch) | |
| tree | 1a1c1a7f59ca008f4aac6be74290fc982d26b3a7 | |
| parent | 44d1dbfbb8a2480a115877ba19a6c4a49757184b (diff) | |
added Mermaid rendering tool
| -rw-r--r-- | source/net/yacy/ai/ToolProvider.java | 2 | ||||
| -rw-r--r-- | source/net/yacy/ai/tools/Mermaid2ASCIITool.java | 4135 | ||||
| -rw-r--r-- | source/net/yacy/ai/tools/PromptToMermaidTool.java | 20 |
3 files changed, 4153 insertions, 4 deletions
diff --git a/source/net/yacy/ai/ToolProvider.java b/source/net/yacy/ai/ToolProvider.java index 070c6dd45..9bef4b223 100644 --- a/source/net/yacy/ai/ToolProvider.java +++ b/source/net/yacy/ai/ToolProvider.java @@ -35,6 +35,7 @@ import net.yacy.ai.tools.ChitChatTool; import net.yacy.ai.tools.DateMathTool; import net.yacy.ai.tools.DateTimeTool; import net.yacy.ai.tools.HttpJsonTool; +import net.yacy.ai.tools.Mermaid2ASCIITool; import net.yacy.ai.tools.NumberParserTool; import net.yacy.ai.tools.PromptToMermaidTool; import net.yacy.ai.tools.SelfReflectTool; @@ -71,6 +72,7 @@ public final class ToolProvider { new SelfReflectTool(), new ChitChatTool(), new PromptToMermaidTool(), + new Mermaid2ASCIITool(), new WikipediaLinkCreatorTool() ); diff --git a/source/net/yacy/ai/tools/Mermaid2ASCIITool.java b/source/net/yacy/ai/tools/Mermaid2ASCIITool.java new file mode 100644 index 000000000..ee7b524c1 --- /dev/null +++ b/source/net/yacy/ai/tools/Mermaid2ASCIITool.java @@ -0,0 +1,4135 @@ +/** + * 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; + } + } +} diff --git a/source/net/yacy/ai/tools/PromptToMermaidTool.java b/source/net/yacy/ai/tools/PromptToMermaidTool.java index dc685f0d9..fe09ff9a3 100644 --- a/source/net/yacy/ai/tools/PromptToMermaidTool.java +++ b/source/net/yacy/ai/tools/PromptToMermaidTool.java @@ -185,17 +185,19 @@ public class PromptToMermaidTool implements ToolHandler { if ("fallback".equals(status)) { String fallbackReason = reasoning.isEmpty() ? "Prompt could not be deterministically mapped to a clear diagram." : reasoning; - return buildResult("fallback", null, "", fallbackReason, warnings, prompt, preferredType); + return buildResult("fallback", null, "", "", fallbackReason, warnings, prompt, preferredType); } - return buildResult("success", diagramType, mermaidCode, reasoning, warnings, prompt, preferredType); + String mermaidAscii = toAsciiDiagram(mermaidCode); + return buildResult("success", diagramType, mermaidCode, mermaidAscii, reasoning, warnings, prompt, preferredType); } - private static JSONObject buildResult(String status, String diagramType, String mermaidCode, String reasoning, + private static JSONObject buildResult(String status, String diagramType, String mermaidCode, String mermaidAscii, String reasoning, JSONArray warnings, String prompt, String preferredType) throws JSONException { JSONObject out = new JSONObject(true); out.put("status", status); out.put("diagram_type", diagramType == null ? JSONObject.NULL : diagramType); out.put("mermaid_code", mermaidCode == null ? "" : mermaidCode); + out.put("mermaid_ascii", mermaidAscii == null ? "" : mermaidAscii); out.put("reasoning", reasoning == null ? "" : reasoning); out.put("warnings", warnings == null ? new JSONArray() : warnings); if (prompt != null && !prompt.isEmpty()) out.put("prompt_redacted", prompt); @@ -266,7 +268,7 @@ public class PromptToMermaidTool implements ToolHandler { if (warning != null && !warning.isEmpty()) w.put(warning); } } - return buildResult("fallback", null, "", + return buildResult("fallback", null, "", "", reason == null ? "Fallback used due to insufficient structure in prompt." : reason, w, prompt, preferredType).toString(); } catch (JSONException e) { @@ -274,6 +276,16 @@ public class PromptToMermaidTool implements ToolHandler { } } + private static String toAsciiDiagram(String mermaidCode) { + if (mermaidCode == null || mermaidCode.trim().isEmpty()) return ""; + try { + // Render in strict ASCII to keep tool output model-friendly and terminal-safe. + return Mermaid2ASCIITool.renderMermaidAscii(mermaidCode, true, 3, 2, 1); + } catch (Exception e) { + return ""; + } + } + private static JSONObject buildResponseSchema() { try { JSONObject schema = new JSONObject(true); |
