From ebc9731d08cc32731a44b8d8812c0b0a67391d59 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 5 May 2026 13:04:31 +0300 Subject: [PATCH 01/14] feat: add mermaid to png cli command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-core/build.gradle | 2 + .../github/istin/dmtools/job/JobRunner.java | 7 + .../dmtools/mermaid/MermaidToPngCommand.java | 62 ++++++ .../dmtools/mermaid/MermaidToPngRenderer.java | 87 ++++++++ .../mermaid/mermaid-flowchart-renderer.js | 187 ++++++++++++++++++ .../mermaid/MermaidToPngCommandTest.java | 67 +++++++ 6 files changed, 412 insertions(+) create mode 100644 dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java create mode 100644 dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java create mode 100644 dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js create mode 100644 dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java diff --git a/dmtools-core/build.gradle b/dmtools-core/build.gradle index 78dd45a5..333d643e 100644 --- a/dmtools-core/build.gradle +++ b/dmtools-core/build.gradle @@ -76,6 +76,8 @@ dependencies { api "org.apache.poi:poi-ooxml:${versions.poi}" api 'org.apache.pdfbox:pdfbox:3.0.5' api 'io.github.furstenheim:copy_down:1.1' + api 'org.apache.xmlgraphics:batik-transcoder:1.18' + api 'org.apache.xmlgraphics:batik-codec:1.18' // Guava and its dependencies api "com.google.guava:guava:${versions.guava}" diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java b/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java index 53792429..f6170c97 100644 --- a/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java +++ b/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java @@ -31,6 +31,7 @@ import com.github.istin.dmtools.teammate.Teammate; import com.github.istin.dmtools.js.JSRunner; import com.github.istin.dmtools.kb.KBProcessingJob; +import com.github.istin.dmtools.mermaid.MermaidToPngCommand; import com.github.istin.dmtools.mcp.cli.McpCliHandler; import java.io.InputStream; @@ -176,6 +177,10 @@ public static void main(String[] args) throws Exception { System.out.println(result); return; } + if ("mermaid_to_png".equals(firstArg)) { + System.out.println(MermaidToPngCommand.execute(args)); + return; + } if ("run".equals(firstArg)) { // Handle new run command with file + optional encoded parameter RunCommandProcessor processor = new RunCommandProcessor(); @@ -281,6 +286,7 @@ private static void printHelp() { System.out.println(" dmtools run # Execute job with JSON config file"); System.out.println(" dmtools run [--key value] # Execute a registered job without a config file"); System.out.println(" dmtools run # Execute job with file + encoded overrides"); + System.out.println(" dmtools mermaid_to_png \"diagram\" # Render Mermaid text to a PNG file"); System.out.println(" dmtools [args...] # Execute MCP tool with args"); System.out.println(" dmtools --data '{\"json\"}' # Execute with inline JSON"); System.out.println(" dmtools --file params.json # Execute with JSON file"); @@ -294,6 +300,7 @@ private static void printHelp() { System.out.println(" dmtools list"); System.out.println(" dmtools run job-config.json"); System.out.println(" dmtools run codegenerator --param1 test"); + System.out.println(" dmtools mermaid_to_png \"flowchart TD; A[Start] --> B[Done]\""); System.out.println(" dmtools run job-config.json \"eyJvdmVycmlkZSI6InZhbHVlIn0=\" # base64 encoded"); System.out.println(" dmtools run job-config.json \"%7B%22override%22%3A%22value%22%7D\" # URL encoded"); System.out.println(" dmtools jira_get_ticket DMC-479 summary,description"); diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java new file mode 100644 index 00000000..5929f5df --- /dev/null +++ b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2024 EPAM Systems, Inc. + +package com.github.istin.dmtools.mermaid; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class MermaidToPngCommand { + + private MermaidToPngCommand() { + } + + public static Path execute(String[] args) throws Exception { + MermaidToPngRequest request = parse(args); + return new MermaidToPngRenderer().renderToPng(request.definition(), request.outputPath()); + } + + static MermaidToPngRequest parse(String[] args) throws Exception { + if (args == null || args.length < 2) { + throw new IllegalArgumentException(usage()); + } + + StringBuilder definition = new StringBuilder(); + Path outputPath = null; + + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + if ("--output".equals(arg) || "-o".equals(arg)) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for " + arg + ". " + usage()); + } + outputPath = Paths.get(args[++i]); + } else if ("--file".equals(arg) || "-f".equals(arg)) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for " + arg + ". " + usage()); + } + definition.append(Files.readString(Paths.get(args[++i]))); + } else { + if (!definition.isEmpty()) { + definition.append(' '); + } + definition.append(arg); + } + } + + String diagram = definition.toString().trim(); + if (diagram.isEmpty()) { + throw new IllegalArgumentException("Mermaid diagram text is required. " + usage()); + } + + return new MermaidToPngRequest(diagram, outputPath); + } + + private static String usage() { + return "Usage: dmtools mermaid_to_png \"flowchart TD; A[Start] --> B[Done]\" [--output output.png]"; + } + + record MermaidToPngRequest(String definition, Path outputPath) { + } +} diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java new file mode 100644 index 00000000..2f000f69 --- /dev/null +++ b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2024 EPAM Systems, Inc. + +package com.github.istin.dmtools.mermaid; + +import org.apache.batik.transcoder.TranscoderException; +import org.apache.batik.transcoder.TranscoderInput; +import org.apache.batik.transcoder.TranscoderOutput; +import org.apache.batik.transcoder.image.PNGTranscoder; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class MermaidToPngRenderer { + + private static final String RENDERER_RESOURCE = "/mermaid/mermaid-flowchart-renderer.js"; + + public Path renderToPng(String definition, Path outputPath) throws IOException, TranscoderException { + if (definition == null || definition.trim().isEmpty()) { + throw new IllegalArgumentException("Mermaid definition is required"); + } + + Path targetPath = outputPath != null + ? outputPath + : Files.createTempFile("dmtools-mermaid-", ".png"); + + Path parent = targetPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + String svg = renderToSvg(definition); + convertSvgToPng(svg, targetPath); + + if (!Files.exists(targetPath) || Files.size(targetPath) == 0) { + throw new IOException("PNG renderer produced an empty file: " + targetPath); + } + + return targetPath.toAbsolutePath().normalize(); + } + + public String renderToSvg(String definition) throws IOException { + try (InputStream stream = getClass().getResourceAsStream(RENDERER_RESOURCE)) { + if (stream == null) { + throw new IOException("Mermaid renderer resource is missing: " + RENDERER_RESOURCE); + } + + Source source = Source.newBuilder( + "js", + new InputStreamReader(stream, StandardCharsets.UTF_8), + "mermaid-flowchart-renderer.js" + ).build(); + + try (Context context = Context.newBuilder("js") + .allowHostAccess(HostAccess.NONE) + .allowHostClassLookup(className -> false) + .allowIO(false) + .option("engine.WarnInterpreterOnly", "false") + .build()) { + context.eval(source); + Value renderFunction = context.getBindings("js").getMember("renderMermaidToSvg"); + if (renderFunction == null || !renderFunction.canExecute()) { + throw new IllegalStateException("Mermaid renderer did not expose renderMermaidToSvg"); + } + return renderFunction.execute(definition).asString(); + } + } + } + + private void convertSvgToPng(String svg, Path outputPath) throws IOException, TranscoderException { + PNGTranscoder transcoder = new PNGTranscoder(); + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8)); + OutputStream outputStream = Files.newOutputStream(outputPath)) { + transcoder.transcode(new TranscoderInput(inputStream), new TranscoderOutput(outputStream)); + } + } +} diff --git a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js new file mode 100644 index 00000000..878cad16 --- /dev/null +++ b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js @@ -0,0 +1,187 @@ +(function () { + 'use strict'; + + function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function normalizeLines(definition) { + return String(definition) + .replace(/\r/g, '') + .split(/\n|;/) + .map(function (line) { return line.trim(); }) + .filter(function (line) { return line && !line.startsWith('%%'); }); + } + + function parseNodeExpression(expression) { + var text = expression.trim(); + var match = text.match(/^([A-Za-z0-9_:. -]+?)\s*(?:\[([^\]]+)\]|\{([^}]+)\}|\(([^)]+)\))?$/); + if (!match) { + throw new Error('Unsupported flowchart node expression: ' + expression); + } + var id = match[1].trim().replace(/\s+/g, '_'); + var label = match[2] || match[3] || match[4] || match[1].trim(); + var shape = match[3] ? 'diamond' : 'rect'; + return { id: id, label: label, shape: shape }; + } + + function ensureNode(nodes, expression) { + var parsed = parseNodeExpression(expression); + var existing = nodes[parsed.id]; + if (existing) { + if (parsed.label && parsed.label !== parsed.id) { + existing.label = parsed.label; + } + if (parsed.shape) { + existing.shape = parsed.shape; + } + return existing; + } + nodes[parsed.id] = parsed; + return parsed; + } + + function parse(definition) { + var lines = normalizeLines(definition); + if (lines.length === 0) { + throw new Error('Mermaid definition is empty'); + } + + var header = lines[0].match(/^(flowchart|graph)\s+([A-Za-z]{2})?/); + if (!header) { + throw new Error('Only Mermaid flowchart/graph definitions are supported by this POC renderer'); + } + + var direction = header[2] || 'TD'; + var nodes = {}; + var edges = []; + + for (var i = 1; i < lines.length; i++) { + var line = lines[i]; + var edge = line.match(/^(.+?)\s*(?:-->|==>|-.->|---)\s*(?:\|([^|]*)\|\s*)?(.+)$/); + if (edge) { + var from = ensureNode(nodes, edge[1]); + var to = ensureNode(nodes, edge[3]); + edges.push({ from: from.id, to: to.id, label: edge[2] || '' }); + } else { + ensureNode(nodes, line); + } + } + + return { + direction: direction, + nodes: Object.keys(nodes).map(function (key) { return nodes[key]; }), + edges: edges + }; + } + + function layout(graph) { + var nodesById = {}; + var indegree = {}; + graph.nodes.forEach(function (node) { + nodesById[node.id] = node; + indegree[node.id] = 0; + }); + graph.edges.forEach(function (edge) { + indegree[edge.to] = (indegree[edge.to] || 0) + 1; + }); + + var rank = {}; + graph.nodes.forEach(function (node) { + rank[node.id] = indegree[node.id] === 0 ? 0 : 1; + }); + + for (var pass = 0; pass < graph.nodes.length; pass++) { + var changed = false; + graph.edges.forEach(function (edge) { + var nextRank = (rank[edge.from] || 0) + 1; + if ((rank[edge.to] || 0) < nextRank) { + rank[edge.to] = nextRank; + changed = true; + } + }); + if (!changed) { + break; + } + } + + var levels = {}; + graph.nodes.forEach(function (node) { + var level = rank[node.id] || 0; + if (!levels[level]) { + levels[level] = []; + } + levels[level].push(node); + }); + + var leftToRight = graph.direction === 'LR' || graph.direction === 'RL'; + var levelGap = 190; + var itemGap = 105; + var margin = 45; + var maxLevel = 0; + var maxItems = 1; + + Object.keys(levels).forEach(function (levelKey) { + var level = Number(levelKey); + maxLevel = Math.max(maxLevel, level); + maxItems = Math.max(maxItems, levels[levelKey].length); + levels[levelKey].forEach(function (node, index) { + var width = Math.max(96, node.label.length * 8 + 34); + var height = node.shape === 'diamond' ? 72 : 50; + node.width = width; + node.height = height; + node.x = margin + (leftToRight ? level * levelGap : index * levelGap); + node.y = margin + (leftToRight ? index * itemGap : level * itemGap); + }); + }); + + graph.width = margin * 2 + (leftToRight ? (maxLevel + 1) * levelGap : maxItems * levelGap); + graph.height = margin * 2 + (leftToRight ? maxItems * itemGap : (maxLevel + 1) * itemGap); + graph.nodesById = nodesById; + return graph; + } + + function nodeCenter(node) { + return { x: node.x + node.width / 2, y: node.y + node.height / 2 }; + } + + function renderNode(node) { + var cx = node.x + node.width / 2; + var cy = node.y + node.height / 2; + var shape; + if (node.shape === 'diamond') { + shape = ''; + } else { + shape = ''; + } + return '' + + shape + + '' + escapeXml(node.label) + '' + + ''; + } + + function renderEdge(edge, graph) { + var from = nodeCenter(graph.nodesById[edge.from]); + var to = nodeCenter(graph.nodesById[edge.to]); + var label = edge.label + ? '' + escapeXml(edge.label) + '' + : ''; + return '' + label + ''; + } + + globalThis.renderMermaidToSvg = function renderMermaidToSvg(definition) { + var graph = layout(parse(definition)); + return '' + + '' + + '' + + '' + + graph.edges.map(function (edge) { return renderEdge(edge, graph); }).join('') + + graph.nodes.map(renderNode).join('') + + ''; + }; +}()); diff --git a/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java b/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java new file mode 100644 index 00000000..fca1de84 --- /dev/null +++ b/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2024 EPAM Systems, Inc. + +package com.github.istin.dmtools.mermaid; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MermaidToPngCommandTest { + + @TempDir + Path tempDir; + + @Test + void renderToSvgReturnsSvgForBasicFlowchart() throws Exception { + String svg = new MermaidToPngRenderer().renderToSvg("flowchart TD; A[Start] --> B[Done]"); + + assertTrue(svg.contains(" B[Done]", outputPath); + + assertEquals(outputPath.toAbsolutePath().normalize(), result); + assertTrue(Files.size(outputPath) > 0); + byte[] header = Files.readAllBytes(outputPath); + assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, new byte[]{header[0], header[1], header[2], header[3]}); + } + + @Test + void commandUsesOutputArgument() throws Exception { + Path outputPath = tempDir.resolve("cli-diagram.png"); + + Path result = MermaidToPngCommand.execute(new String[]{ + "mermaid_to_png", + "flowchart TD; A[Start] --> B[Done]", + "--output", + outputPath.toString() + }); + + assertEquals(outputPath.toAbsolutePath().normalize(), result); + assertTrue(Files.exists(outputPath)); + } + + @Test + void commandRequiresDiagramText() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> MermaidToPngCommand.execute(new String[]{"mermaid_to_png"}) + ); + + assertTrue(exception.getMessage().contains("Usage: dmtools mermaid_to_png")); + } +} From d3706a88fb3ae099ed133ce2024d4d39026dabcb Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 5 May 2026 13:12:03 +0300 Subject: [PATCH 02/14] fix: improve mermaid png flowchart rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mermaid/mermaid-flowchart-renderer.js | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js index 878cad16..eacfee0e 100644 --- a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js +++ b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js @@ -25,8 +25,9 @@ throw new Error('Unsupported flowchart node expression: ' + expression); } var id = match[1].trim().replace(/\s+/g, '_'); - var label = match[2] || match[3] || match[4] || match[1].trim(); - var shape = match[3] ? 'diamond' : 'rect'; + var explicitLabel = match[2] || match[3] || match[4] || ''; + var label = explicitLabel || match[1].trim(); + var shape = match[3] ? 'diamond' : (explicitLabel ? 'rect' : null); return { id: id, label: label, shape: shape }; } @@ -42,6 +43,7 @@ } return existing; } + parsed.shape = parsed.shape || 'rect'; nodes[parsed.id] = parsed; return parsed; } @@ -150,6 +152,26 @@ return { x: node.x + node.width / 2, y: node.y + node.height / 2 }; } + function edgeAnchor(node, otherNode) { + var center = nodeCenter(node); + var otherCenter = nodeCenter(otherNode); + var dx = otherCenter.x - center.x; + var dy = otherCenter.y - center.y; + var horizontal = Math.abs(dx) / node.width > Math.abs(dy) / node.height; + + if (horizontal) { + return { + x: center.x + (dx >= 0 ? node.width / 2 : -node.width / 2), + y: center.y + }; + } + + return { + x: center.x, + y: center.y + (dy >= 0 ? node.height / 2 : -node.height / 2) + }; + } + function renderNode(node) { var cx = node.x + node.width / 2; var cy = node.y + node.height / 2; @@ -166,11 +188,26 @@ } function renderEdge(edge, graph) { - var from = nodeCenter(graph.nodesById[edge.from]); - var to = nodeCenter(graph.nodesById[edge.to]); - var label = edge.label - ? '' + escapeXml(edge.label) + '' - : ''; + var fromNode = graph.nodesById[edge.from]; + var toNode = graph.nodesById[edge.to]; + var from = edgeAnchor(fromNode, toNode); + var to = edgeAnchor(toNode, fromNode); + var label = ''; + if (edge.label) { + var dx = to.x - from.x; + var dy = to.y - from.y; + var length = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + var normalX = -dy / length; + var normalY = dx / length; + var offset = 18; + var labelX = (from.x + to.x) / 2 + normalX * offset; + var labelY = (from.y + to.y) / 2 + normalY * offset; + var labelWidth = Math.max(34, edge.label.length * 8 + 14); + label = '' + + '' + + '' + escapeXml(edge.label) + '' + + ''; + } return '' + label + ''; } @@ -179,7 +216,8 @@ return '' + '' + '' + - '' + + '' + + '' + graph.edges.map(function (edge) { return renderEdge(edge, graph); }).join('') + graph.nodes.map(renderNode).join('') + ''; From ed98ef8183bbb060428c9cdfb1379b7f4918a74c Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 5 May 2026 13:20:12 +0300 Subject: [PATCH 03/14] test: cover mermaid png diagram types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dmtools/mermaid/MermaidToPngRenderer.java | 7 +- .../mermaid/mermaid-flowchart-renderer.js | 413 ++++++++++++++---- .../mermaid/MermaidToPngCommandTest.java | 95 +++- 3 files changed, 437 insertions(+), 78 deletions(-) diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java index 2f000f69..1f1e4063 100644 --- a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java +++ b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java @@ -9,6 +9,7 @@ import org.apache.batik.transcoder.image.PNGTranscoder; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.PolyglotException; import org.graalvm.polyglot.Source; import org.graalvm.polyglot.Value; @@ -72,7 +73,11 @@ public String renderToSvg(String definition) throws IOException { if (renderFunction == null || !renderFunction.canExecute()) { throw new IllegalStateException("Mermaid renderer did not expose renderMermaidToSvg"); } - return renderFunction.execute(definition).asString(); + try { + return renderFunction.execute(definition).asString(); + } catch (PolyglotException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } } } } diff --git a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js index eacfee0e..78642760 100644 --- a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js +++ b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js @@ -18,7 +18,21 @@ .filter(function (line) { return line && !line.startsWith('%%'); }); } - function parseNodeExpression(expression) { + function textWidth(text, minimum) { + return Math.max(minimum || 80, String(text || '').length * 8 + 34); + } + + function renderDocument(width, height, body, extraStyle) { + return '' + + '' + + '' + + '' + + '' + + body + + ''; + } + + function parseFlowNodeExpression(expression) { var text = expression.trim(); var match = text.match(/^([A-Za-z0-9_:. -]+?)\s*(?:\[([^\]]+)\]|\{([^}]+)\}|\(([^)]+)\))?$/); if (!match) { @@ -31,8 +45,8 @@ return { id: id, label: label, shape: shape }; } - function ensureNode(nodes, expression) { - var parsed = parseNodeExpression(expression); + function ensureFlowNode(nodes, expression) { + var parsed = parseFlowNodeExpression(expression); var existing = nodes[parsed.id]; if (existing) { if (parsed.label && parsed.label !== parsed.id) { @@ -48,17 +62,8 @@ return parsed; } - function parse(definition) { - var lines = normalizeLines(definition); - if (lines.length === 0) { - throw new Error('Mermaid definition is empty'); - } - + function parseFlowchart(lines) { var header = lines[0].match(/^(flowchart|graph)\s+([A-Za-z]{2})?/); - if (!header) { - throw new Error('Only Mermaid flowchart/graph definitions are supported by this POC renderer'); - } - var direction = header[2] || 'TD'; var nodes = {}; var edges = []; @@ -67,11 +72,11 @@ var line = lines[i]; var edge = line.match(/^(.+?)\s*(?:-->|==>|-.->|---)\s*(?:\|([^|]*)\|\s*)?(.+)$/); if (edge) { - var from = ensureNode(nodes, edge[1]); - var to = ensureNode(nodes, edge[3]); + var from = ensureFlowNode(nodes, edge[1]); + var to = ensureFlowNode(nodes, edge[3]); edges.push({ from: from.id, to: to.id, label: edge[2] || '' }); } else { - ensureNode(nodes, line); + ensureFlowNode(nodes, line); } } @@ -82,7 +87,7 @@ }; } - function layout(graph) { + function layoutFlowchart(graph) { var nodesById = {}; var indegree = {}; graph.nodes.forEach(function (node) { @@ -94,24 +99,34 @@ }); var rank = {}; + var queue = []; graph.nodes.forEach(function (node) { - rank[node.id] = indegree[node.id] === 0 ? 0 : 1; + if (indegree[node.id] === 0) { + rank[node.id] = 0; + queue.push(node.id); + } }); + if (queue.length === 0 && graph.nodes.length > 0) { + rank[graph.nodes[0].id] = 0; + queue.push(graph.nodes[0].id); + } - for (var pass = 0; pass < graph.nodes.length; pass++) { - var changed = false; + while (queue.length > 0) { + var current = queue.shift(); graph.edges.forEach(function (edge) { - var nextRank = (rank[edge.from] || 0) + 1; - if ((rank[edge.to] || 0) < nextRank) { - rank[edge.to] = nextRank; - changed = true; + if (edge.from === current && rank[edge.to] === undefined) { + rank[edge.to] = rank[current] + 1; + queue.push(edge.to); } }); - if (!changed) { - break; - } } + graph.nodes.forEach(function (node) { + if (rank[node.id] === undefined) { + rank[node.id] = 0; + } + }); + var levels = {}; graph.nodes.forEach(function (node) { var level = rank[node.id] || 0; @@ -122,28 +137,27 @@ }); var leftToRight = graph.direction === 'LR' || graph.direction === 'RL'; - var levelGap = 190; - var itemGap = 105; + var levelGap = 210; + var itemGap = 120; var margin = 45; - var maxLevel = 0; - var maxItems = 1; + var maxX = margin; + var maxY = margin; Object.keys(levels).forEach(function (levelKey) { var level = Number(levelKey); - maxLevel = Math.max(maxLevel, level); - maxItems = Math.max(maxItems, levels[levelKey].length); levels[levelKey].forEach(function (node, index) { - var width = Math.max(96, node.label.length * 8 + 34); - var height = node.shape === 'diamond' ? 72 : 50; - node.width = width; - node.height = height; + node.width = textWidth(node.label, node.shape === 'diamond' ? 112 : 96); + node.height = node.shape === 'diamond' ? 76 : 52; node.x = margin + (leftToRight ? level * levelGap : index * levelGap); node.y = margin + (leftToRight ? index * itemGap : level * itemGap); + maxX = Math.max(maxX, node.x + node.width); + maxY = Math.max(maxY, node.y + node.height); }); }); - graph.width = margin * 2 + (leftToRight ? (maxLevel + 1) * levelGap : maxItems * levelGap); - graph.height = margin * 2 + (leftToRight ? maxItems * itemGap : (maxLevel + 1) * itemGap); + graph.routeRight = maxX + 80; + graph.width = Math.ceil(maxX + margin + 220); + graph.height = Math.ceil(maxY + margin); graph.nodesById = nodesById; return graph; } @@ -160,19 +174,12 @@ var horizontal = Math.abs(dx) / node.width > Math.abs(dy) / node.height; if (horizontal) { - return { - x: center.x + (dx >= 0 ? node.width / 2 : -node.width / 2), - y: center.y - }; + return { x: center.x + (dx >= 0 ? node.width / 2 : -node.width / 2), y: center.y }; } - - return { - x: center.x, - y: center.y + (dy >= 0 ? node.height / 2 : -node.height / 2) - }; + return { x: center.x, y: center.y + (dy >= 0 ? node.height / 2 : -node.height / 2) }; } - function renderNode(node) { + function renderFlowNode(node) { var cx = node.x + node.width / 2; var cy = node.y + node.height / 2; var shape; @@ -181,45 +188,303 @@ } else { shape = ''; } - return '' + - shape + - '' + escapeXml(node.label) + '' + - ''; + return '' + shape + + '' + escapeXml(node.label) + ''; } - function renderEdge(edge, graph) { + function renderFlowEdge(edge, graph) { var fromNode = graph.nodesById[edge.from]; var toNode = graph.nodesById[edge.to]; var from = edgeAnchor(fromNode, toNode); var to = edgeAnchor(toNode, fromNode); + var backward = to.y < from.y - 5 || edge.from === edge.to; var label = ''; if (edge.label) { - var dx = to.x - from.x; - var dy = to.y - from.y; + var dx = backward ? 0 : to.x - from.x; + var dy = backward ? to.y - from.y : to.y - from.y; var length = Math.max(Math.sqrt(dx * dx + dy * dy), 1); - var normalX = -dy / length; - var normalY = dx / length; - var offset = 18; - var labelX = (from.x + to.x) / 2 + normalX * offset; - var labelY = (from.y + to.y) / 2 + normalY * offset; + var labelX = backward ? graph.routeRight + 10 : (from.x + to.x) / 2 + (-dy / length) * 18; + var labelY = (from.y + to.y) / 2 + (backward ? 0 : (dx / length) * 18); var labelWidth = Math.max(34, edge.label.length * 8 + 14); - label = '' + - '' + - '' + escapeXml(edge.label) + '' + - ''; + label = '' + + '' + escapeXml(edge.label) + ''; + } + if (backward) { + var routeX = graph.routeRight; + return '' + label + ''; } return '' + label + ''; } + function renderFlowchart(lines) { + var graph = layoutFlowchart(parseFlowchart(lines)); + return renderDocument( + graph.width, + graph.height, + graph.edges.map(function (edge) { return renderFlowEdge(edge, graph); }).join('') + graph.nodes.map(renderFlowNode).join('') + ); + } + + function parseSequence(lines) { + var participants = []; + var participantMap = {}; + var messages = []; + + function ensureParticipant(name, label) { + var id = name.trim(); + if (!participantMap[id]) { + participantMap[id] = { id: id, label: label || id }; + participants.push(participantMap[id]); + } else if (label) { + participantMap[id].label = label; + } + return participantMap[id]; + } + + for (var i = 1; i < lines.length; i++) { + var line = lines[i]; + var participant = line.match(/^(participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?$/); + if (participant) { + ensureParticipant(participant[2], participant[3] || participant[2]); + continue; + } + var note = line.match(/^Note\s+(?:over|right of|left of)\s+([A-Za-z0-9_, ]+)\s*:\s*(.+)$/); + if (note) { + messages.push({ type: 'note', target: note[1].split(',')[0].trim(), text: note[2] }); + ensureParticipant(note[1].split(',')[0].trim()); + continue; + } + var message = line.match(/^([A-Za-z0-9_]+)\s*[-=.]+>>?\s*([A-Za-z0-9_]+)\s*:\s*(.+)$/); + if (message) { + ensureParticipant(message[1]); + ensureParticipant(message[2]); + messages.push({ type: 'message', from: message[1], to: message[2], text: message[3], dashed: line.indexOf('--') !== -1 }); + } + } + return { participants: participants, messages: messages }; + } + + function renderSequence(lines) { + var diagram = parseSequence(lines); + var margin = 45; + var headerY = 35; + var laneGap = 180; + var step = 68; + var width = Math.max(320, margin * 2 + Math.max(1, diagram.participants.length - 1) * laneGap + 120); + var height = 115 + Math.max(1, diagram.messages.length) * step; + var positions = {}; + var body = ''; + + diagram.participants.forEach(function (participant, index) { + var x = margin + 60 + index * laneGap; + positions[participant.id] = x; + var boxWidth = textWidth(participant.label, 100); + body += '' + + '' + escapeXml(participant.label) + '' + + ''; + }); + + diagram.messages.forEach(function (message, index) { + var y = 115 + index * step; + if (message.type === 'note') { + var noteX = positions[message.target] || margin + 60; + var noteWidth = textWidth(message.text, 150); + body += '' + + '' + escapeXml(message.text) + ''; + } else { + var fromX = positions[message.from]; + var toX = positions[message.to]; + var textX = (fromX + toX) / 2; + var dash = message.dashed ? ' stroke-dasharray="6 4"' : ''; + body += '' + + '' + + '' + escapeXml(message.text) + ''; + } + }); + + return renderDocument(width, height, body); + } + + function parseClass(lines) { + var classes = {}; + var relations = []; + var currentClass = null; + + function ensureClass(name) { + if (!classes[name]) { + classes[name] = { name: name, members: [] }; + } + return classes[name]; + } + + for (var i = 1; i < lines.length; i++) { + var line = lines[i]; + var classStart = line.match(/^class\s+([A-Za-z0-9_]+)(?:\s*\{)?$/); + if (classStart) { + currentClass = ensureClass(classStart[1]); + if (line.indexOf('{') === -1) { + currentClass = null; + } + continue; + } + if (line === '}') { + currentClass = null; + continue; + } + var member = line.match(/^([A-Za-z0-9_]+)\s*:\s*(.+)$/); + if (member) { + ensureClass(member[1]).members.push(member[2]); + continue; + } + var relation = line.match(/^([A-Za-z0-9_]+)\s+[<|*o. -]*--[>|*o. -]*\s+([A-Za-z0-9_]+)(?:\s*:\s*(.+))?$/); + if (relation) { + ensureClass(relation[1]); + ensureClass(relation[2]); + relations.push({ from: relation[1], to: relation[2], label: relation[3] || '' }); + continue; + } + if (currentClass) { + currentClass.members.push(line); + } + } + return { classes: Object.keys(classes).map(function (key) { return classes[key]; }), relations: relations }; + } + + function renderClassDiagram(lines) { + var diagram = parseClass(lines); + var margin = 45; + var rowGap = 160; + var columns = Math.min(2, Math.max(1, diagram.classes.length)); + var maxClassWidth = 170; + diagram.classes.forEach(function (klass) { + maxClassWidth = Math.max(maxClassWidth, textWidth(klass.name, 170)); + klass.members.forEach(function (member) { + maxClassWidth = Math.max(maxClassWidth, textWidth(member, 170)); + }); + }); + var columnGap = 85; + var width = margin * 2 + columns * maxClassWidth + Math.max(0, columns - 1) * columnGap; + var rows = Math.ceil(diagram.classes.length / columns); + var height = margin * 2 + rows * rowGap; + var positions = {}; + var body = ''; + + diagram.classes.forEach(function (klass, index) { + var col = index % columns; + var row = Math.floor(index / columns); + var x = margin + col * (maxClassWidth + columnGap); + var y = margin + row * rowGap; + var boxHeight = 48 + Math.max(1, klass.members.length) * 22; + positions[klass.name] = { x: x, y: y, width: maxClassWidth, height: boxHeight }; + body += '' + + '' + + '' + escapeXml(klass.name) + ''; + if (klass.members.length === 0) { + body += ' '; + } + klass.members.forEach(function (member, memberIndex) { + body += '' + escapeXml(member) + ''; + }); + body += ''; + }); + + diagram.relations.forEach(function (relation) { + var from = positions[relation.from]; + var to = positions[relation.to]; + var x1 = from.x + from.width; + var y1 = from.y + from.height / 2; + var x2 = to.x; + var y2 = to.y + to.height / 2; + if (x2 < x1) { + x1 = from.x + from.width / 2; + y1 = from.y + from.height; + x2 = to.x + to.width / 2; + y2 = to.y; + } + var label = relation.label ? '' + escapeXml(relation.label) + '' : ''; + body = '' + label + body; + }); + + return renderDocument(width, height, body); + } + + function parseState(lines) { + var states = {}; + var edges = []; + + function stateId(name) { + return name === '[*]' ? 'start_end_' + Object.keys(states).length : name.replace(/\s+/g, '_'); + } + + function ensureState(name) { + var label = name.trim(); + var id = label === '[*]' ? stateId(label) : label.replace(/\s+/g, '_'); + if (!states[id]) { + states[id] = { id: id, label: label, terminal: label === '[*]' }; + } + return states[id]; + } + + for (var i = 1; i < lines.length; i++) { + var line = lines[i]; + var edge = line.match(/^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.+))?$/); + if (edge) { + var from = ensureState(edge[1].trim()); + var to = ensureState(edge[2].trim()); + edges.push({ from: from.id, to: to.id, label: edge[3] || '' }); + } else if (line.indexOf('state ') === 0) { + ensureState(line.replace(/^state\s+/, '').trim()); + } else { + ensureState(line); + } + } + return { nodes: Object.keys(states).map(function (key) { return states[key]; }), edges: edges, direction: 'TD' }; + } + + function renderStateNode(node) { + if (node.terminal) { + return ''; + } + node.width = node.width || textWidth(node.label, 110); + node.height = node.height || 48; + return '' + + '' + escapeXml(node.label) + ''; + } + + function renderStateDiagram(lines) { + var graph = parseState(lines); + graph.nodes.forEach(function (node) { + node.width = node.terminal ? 36 : textWidth(node.label, 110); + node.height = node.terminal ? 36 : 48; + node.shape = 'rect'; + }); + graph = layoutFlowchart(graph); + graph.nodesById = {}; + graph.nodes.forEach(function (node) { graph.nodesById[node.id] = node; }); + return renderDocument( + graph.width, + graph.height, + graph.edges.map(function (edge) { return renderFlowEdge(edge, graph); }).join('') + graph.nodes.map(renderStateNode).join('') + ); + } + globalThis.renderMermaidToSvg = function renderMermaidToSvg(definition) { - var graph = layout(parse(definition)); - return '' + - '' + - '' + - '' + - '' + - graph.edges.map(function (edge) { return renderEdge(edge, graph); }).join('') + - graph.nodes.map(renderNode).join('') + - ''; + var lines = normalizeLines(definition); + if (lines.length === 0) { + throw new Error('Mermaid definition is empty'); + } + if (/^(flowchart|graph)\s+/.test(lines[0])) { + return renderFlowchart(lines); + } + if (lines[0] === 'sequenceDiagram') { + return renderSequence(lines); + } + if (lines[0] === 'classDiagram') { + return renderClassDiagram(lines); + } + if (/^stateDiagram(?:-v2)?$/.test(lines[0])) { + return renderStateDiagram(lines); + } + throw new Error('Unsupported Mermaid diagram type: ' + lines[0]); }; }()); diff --git a/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java b/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java index fca1de84..0b38ae16 100644 --- a/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java +++ b/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java @@ -5,9 +5,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.nio.file.Files; import java.nio.file.Path; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -19,6 +22,55 @@ class MermaidToPngCommandTest { @TempDir Path tempDir; + static Stream supportedDiagrams() { + return Stream.of( + new DiagramFixture( + "flowchart", + "flowchart TD; A[Start] --> B{Check}; B -->|Yes| C[Done]; B -->|No| D[Retry]", + "Check" + ), + new DiagramFixture( + "sequence", + """ + sequenceDiagram + participant User + participant API + participant Jira + User->>API: Request status + API->>Jira: Fetch issue + Jira-->>API: Issue payload + API-->>User: Render response + """, + "Request status" + ), + new DiagramFixture( + "class", + """ + classDiagram + class Story + Story : +String key + Story : +Status status + class Blocker + Blocker : +resolve() + Story --> Blocker : blocked by + """, + "Story" + ), + new DiagramFixture( + "state", + """ + stateDiagram-v2 + [*] --> Blocked + Blocked --> Backlog : blockers done + Backlog --> InProgress : sprint starts + InProgress --> Done : accepted + Done --> [*] + """, + "Blocked" + ) + ); + } + @Test void renderToSvgReturnsSvgForBasicFlowchart() throws Exception { String svg = new MermaidToPngRenderer().renderToSvg("flowchart TD; A[Start] --> B[Done]"); @@ -28,6 +80,26 @@ void renderToSvgReturnsSvgForBasicFlowchart() throws Exception { assertTrue(svg.contains("Done")); } + @ParameterizedTest + @MethodSource("supportedDiagrams") + void renderToSvgSupportsDiagramTypes(DiagramFixture fixture) throws Exception { + String svg = new MermaidToPngRenderer().renderToSvg(fixture.definition()); + + assertTrue(svg.contains(" B[Done]", outputPath); assertEquals(outputPath.toAbsolutePath().normalize(), result); - assertTrue(Files.size(outputPath) > 0); - byte[] header = Files.readAllBytes(outputPath); - assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, new byte[]{header[0], header[1], header[2], header[3]}); + assertPng(outputPath); } @Test @@ -64,4 +134,23 @@ void commandRequiresDiagramText() { assertTrue(exception.getMessage().contains("Usage: dmtools mermaid_to_png")); } + + @Test + void renderToSvgRejectsUnsupportedDiagramTypes() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new MermaidToPngRenderer().renderToPng("pie title Pets", tempDir.resolve("pie.png")) + ); + + assertTrue(exception.getMessage().contains("Unsupported Mermaid diagram type")); + } + + private void assertPng(Path outputPath) throws Exception { + assertTrue(Files.size(outputPath) > 0); + byte[] header = Files.readAllBytes(outputPath); + assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, new byte[]{header[0], header[1], header[2], header[3]}); + } + + record DiagramFixture(String name, String definition, String expectedText) { + } } From 1e4b415ac01884bd5a76a0d3074b485ed6891a84 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 5 May 2026 13:50:53 +0300 Subject: [PATCH 04/14] feat: wire external mermaid renderer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitmodules | 3 + dmtools-core/build.gradle | 3 +- .../github/istin/dmtools/job/JobRunner.java | 8 +- .../dmtools/mermaid/MermaidToPngCommand.java | 62 --- .../dmtools/mermaid/MermaidToPngRenderer.java | 92 ---- .../mermaid/mermaid-flowchart-renderer.js | 490 ------------------ .../job/JobRunnerMcpIntegrationTest.java | 44 +- .../mermaid/MermaidToPngCommandTest.java | 156 ------ dmtools-mermaid-renderer | 1 + settings.gradle | 1 + 10 files changed, 54 insertions(+), 806 deletions(-) delete mode 100644 dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java delete mode 100644 dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java delete mode 100644 dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js delete mode 100644 dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java create mode 160000 dmtools-mermaid-renderer diff --git a/.gitmodules b/.gitmodules index 0c824f63..ea453119 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "agents"] path = agents url = git@github.com:IstiN/dmtools-agents.git +[submodule "dmtools-mermaid-renderer"] + path = dmtools-mermaid-renderer + url = git@github.com:IstiN/dmtools-mermaid-renderer.git diff --git a/dmtools-core/build.gradle b/dmtools-core/build.gradle index 333d643e..b2e6f379 100644 --- a/dmtools-core/build.gradle +++ b/dmtools-core/build.gradle @@ -76,8 +76,6 @@ dependencies { api "org.apache.poi:poi-ooxml:${versions.poi}" api 'org.apache.pdfbox:pdfbox:3.0.5' api 'io.github.furstenheim:copy_down:1.1' - api 'org.apache.xmlgraphics:batik-transcoder:1.18' - api 'org.apache.xmlgraphics:batik-codec:1.18' // Guava and its dependencies api "com.google.guava:guava:${versions.guava}" @@ -106,6 +104,7 @@ dependencies { // MCP annotations from separate module api project(':dmtools-mcp-annotations') + api 'com.github.istin:dmtools-mermaid-renderer:0.1.0-SNAPSHOT' // MCP Tools annotation processor - now in separate module annotationProcessor project(':dmtools-annotation-processor') diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java b/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java index f6170c97..dfdcf965 100644 --- a/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java +++ b/dmtools-core/src/main/java/com/github/istin/dmtools/job/JobRunner.java @@ -31,7 +31,7 @@ import com.github.istin.dmtools.teammate.Teammate; import com.github.istin.dmtools.js.JSRunner; import com.github.istin.dmtools.kb.KBProcessingJob; -import com.github.istin.dmtools.mermaid.MermaidToPngCommand; +import com.github.istin.dmtools.mermaid.MermaidRendererCli; import com.github.istin.dmtools.mcp.cli.McpCliHandler; import java.io.InputStream; @@ -177,8 +177,8 @@ public static void main(String[] args) throws Exception { System.out.println(result); return; } - if ("mermaid_to_png".equals(firstArg)) { - System.out.println(MermaidToPngCommand.execute(args)); + if ("mermaid_to_svg".equals(firstArg) || "mermaid_to_png".equals(firstArg)) { + System.out.println(MermaidRendererCli.execute(args)); return; } if ("run".equals(firstArg)) { @@ -286,6 +286,7 @@ private static void printHelp() { System.out.println(" dmtools run # Execute job with JSON config file"); System.out.println(" dmtools run [--key value] # Execute a registered job without a config file"); System.out.println(" dmtools run # Execute job with file + encoded overrides"); + System.out.println(" dmtools mermaid_to_svg \"diagram\" # Render Mermaid text to an SVG file"); System.out.println(" dmtools mermaid_to_png \"diagram\" # Render Mermaid text to a PNG file"); System.out.println(" dmtools [args...] # Execute MCP tool with args"); System.out.println(" dmtools --data '{\"json\"}' # Execute with inline JSON"); @@ -300,6 +301,7 @@ private static void printHelp() { System.out.println(" dmtools list"); System.out.println(" dmtools run job-config.json"); System.out.println(" dmtools run codegenerator --param1 test"); + System.out.println(" dmtools mermaid_to_svg \"flowchart TD; A[Start] --> B[Done]\" --output diagram.svg"); System.out.println(" dmtools mermaid_to_png \"flowchart TD; A[Start] --> B[Done]\""); System.out.println(" dmtools run job-config.json \"eyJvdmVycmlkZSI6InZhbHVlIn0=\" # base64 encoded"); System.out.println(" dmtools run job-config.json \"%7B%22override%22%3A%22value%22%7D\" # URL encoded"); diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java deleted file mode 100644 index 5929f5df..00000000 --- a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngCommand.java +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2024 EPAM Systems, Inc. - -package com.github.istin.dmtools.mermaid; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -public final class MermaidToPngCommand { - - private MermaidToPngCommand() { - } - - public static Path execute(String[] args) throws Exception { - MermaidToPngRequest request = parse(args); - return new MermaidToPngRenderer().renderToPng(request.definition(), request.outputPath()); - } - - static MermaidToPngRequest parse(String[] args) throws Exception { - if (args == null || args.length < 2) { - throw new IllegalArgumentException(usage()); - } - - StringBuilder definition = new StringBuilder(); - Path outputPath = null; - - for (int i = 1; i < args.length; i++) { - String arg = args[i]; - if ("--output".equals(arg) || "-o".equals(arg)) { - if (i + 1 >= args.length) { - throw new IllegalArgumentException("Missing value for " + arg + ". " + usage()); - } - outputPath = Paths.get(args[++i]); - } else if ("--file".equals(arg) || "-f".equals(arg)) { - if (i + 1 >= args.length) { - throw new IllegalArgumentException("Missing value for " + arg + ". " + usage()); - } - definition.append(Files.readString(Paths.get(args[++i]))); - } else { - if (!definition.isEmpty()) { - definition.append(' '); - } - definition.append(arg); - } - } - - String diagram = definition.toString().trim(); - if (diagram.isEmpty()) { - throw new IllegalArgumentException("Mermaid diagram text is required. " + usage()); - } - - return new MermaidToPngRequest(diagram, outputPath); - } - - private static String usage() { - return "Usage: dmtools mermaid_to_png \"flowchart TD; A[Start] --> B[Done]\" [--output output.png]"; - } - - record MermaidToPngRequest(String definition, Path outputPath) { - } -} diff --git a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java b/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java deleted file mode 100644 index 1f1e4063..00000000 --- a/dmtools-core/src/main/java/com/github/istin/dmtools/mermaid/MermaidToPngRenderer.java +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2024 EPAM Systems, Inc. - -package com.github.istin.dmtools.mermaid; - -import org.apache.batik.transcoder.TranscoderException; -import org.apache.batik.transcoder.TranscoderInput; -import org.apache.batik.transcoder.TranscoderOutput; -import org.apache.batik.transcoder.image.PNGTranscoder; -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.HostAccess; -import org.graalvm.polyglot.PolyglotException; -import org.graalvm.polyglot.Source; -import org.graalvm.polyglot.Value; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class MermaidToPngRenderer { - - private static final String RENDERER_RESOURCE = "/mermaid/mermaid-flowchart-renderer.js"; - - public Path renderToPng(String definition, Path outputPath) throws IOException, TranscoderException { - if (definition == null || definition.trim().isEmpty()) { - throw new IllegalArgumentException("Mermaid definition is required"); - } - - Path targetPath = outputPath != null - ? outputPath - : Files.createTempFile("dmtools-mermaid-", ".png"); - - Path parent = targetPath.toAbsolutePath().getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - - String svg = renderToSvg(definition); - convertSvgToPng(svg, targetPath); - - if (!Files.exists(targetPath) || Files.size(targetPath) == 0) { - throw new IOException("PNG renderer produced an empty file: " + targetPath); - } - - return targetPath.toAbsolutePath().normalize(); - } - - public String renderToSvg(String definition) throws IOException { - try (InputStream stream = getClass().getResourceAsStream(RENDERER_RESOURCE)) { - if (stream == null) { - throw new IOException("Mermaid renderer resource is missing: " + RENDERER_RESOURCE); - } - - Source source = Source.newBuilder( - "js", - new InputStreamReader(stream, StandardCharsets.UTF_8), - "mermaid-flowchart-renderer.js" - ).build(); - - try (Context context = Context.newBuilder("js") - .allowHostAccess(HostAccess.NONE) - .allowHostClassLookup(className -> false) - .allowIO(false) - .option("engine.WarnInterpreterOnly", "false") - .build()) { - context.eval(source); - Value renderFunction = context.getBindings("js").getMember("renderMermaidToSvg"); - if (renderFunction == null || !renderFunction.canExecute()) { - throw new IllegalStateException("Mermaid renderer did not expose renderMermaidToSvg"); - } - try { - return renderFunction.execute(definition).asString(); - } catch (PolyglotException e) { - throw new IllegalArgumentException(e.getMessage(), e); - } - } - } - } - - private void convertSvgToPng(String svg, Path outputPath) throws IOException, TranscoderException { - PNGTranscoder transcoder = new PNGTranscoder(); - try (ByteArrayInputStream inputStream = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8)); - OutputStream outputStream = Files.newOutputStream(outputPath)) { - transcoder.transcode(new TranscoderInput(inputStream), new TranscoderOutput(outputStream)); - } - } -} diff --git a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js b/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js deleted file mode 100644 index 78642760..00000000 --- a/dmtools-core/src/main/resources/mermaid/mermaid-flowchart-renderer.js +++ /dev/null @@ -1,490 +0,0 @@ -(function () { - 'use strict'; - - function escapeXml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function normalizeLines(definition) { - return String(definition) - .replace(/\r/g, '') - .split(/\n|;/) - .map(function (line) { return line.trim(); }) - .filter(function (line) { return line && !line.startsWith('%%'); }); - } - - function textWidth(text, minimum) { - return Math.max(minimum || 80, String(text || '').length * 8 + 34); - } - - function renderDocument(width, height, body, extraStyle) { - return '' + - '' + - '' + - '' + - '' + - body + - ''; - } - - function parseFlowNodeExpression(expression) { - var text = expression.trim(); - var match = text.match(/^([A-Za-z0-9_:. -]+?)\s*(?:\[([^\]]+)\]|\{([^}]+)\}|\(([^)]+)\))?$/); - if (!match) { - throw new Error('Unsupported flowchart node expression: ' + expression); - } - var id = match[1].trim().replace(/\s+/g, '_'); - var explicitLabel = match[2] || match[3] || match[4] || ''; - var label = explicitLabel || match[1].trim(); - var shape = match[3] ? 'diamond' : (explicitLabel ? 'rect' : null); - return { id: id, label: label, shape: shape }; - } - - function ensureFlowNode(nodes, expression) { - var parsed = parseFlowNodeExpression(expression); - var existing = nodes[parsed.id]; - if (existing) { - if (parsed.label && parsed.label !== parsed.id) { - existing.label = parsed.label; - } - if (parsed.shape) { - existing.shape = parsed.shape; - } - return existing; - } - parsed.shape = parsed.shape || 'rect'; - nodes[parsed.id] = parsed; - return parsed; - } - - function parseFlowchart(lines) { - var header = lines[0].match(/^(flowchart|graph)\s+([A-Za-z]{2})?/); - var direction = header[2] || 'TD'; - var nodes = {}; - var edges = []; - - for (var i = 1; i < lines.length; i++) { - var line = lines[i]; - var edge = line.match(/^(.+?)\s*(?:-->|==>|-.->|---)\s*(?:\|([^|]*)\|\s*)?(.+)$/); - if (edge) { - var from = ensureFlowNode(nodes, edge[1]); - var to = ensureFlowNode(nodes, edge[3]); - edges.push({ from: from.id, to: to.id, label: edge[2] || '' }); - } else { - ensureFlowNode(nodes, line); - } - } - - return { - direction: direction, - nodes: Object.keys(nodes).map(function (key) { return nodes[key]; }), - edges: edges - }; - } - - function layoutFlowchart(graph) { - var nodesById = {}; - var indegree = {}; - graph.nodes.forEach(function (node) { - nodesById[node.id] = node; - indegree[node.id] = 0; - }); - graph.edges.forEach(function (edge) { - indegree[edge.to] = (indegree[edge.to] || 0) + 1; - }); - - var rank = {}; - var queue = []; - graph.nodes.forEach(function (node) { - if (indegree[node.id] === 0) { - rank[node.id] = 0; - queue.push(node.id); - } - }); - if (queue.length === 0 && graph.nodes.length > 0) { - rank[graph.nodes[0].id] = 0; - queue.push(graph.nodes[0].id); - } - - while (queue.length > 0) { - var current = queue.shift(); - graph.edges.forEach(function (edge) { - if (edge.from === current && rank[edge.to] === undefined) { - rank[edge.to] = rank[current] + 1; - queue.push(edge.to); - } - }); - } - - graph.nodes.forEach(function (node) { - if (rank[node.id] === undefined) { - rank[node.id] = 0; - } - }); - - var levels = {}; - graph.nodes.forEach(function (node) { - var level = rank[node.id] || 0; - if (!levels[level]) { - levels[level] = []; - } - levels[level].push(node); - }); - - var leftToRight = graph.direction === 'LR' || graph.direction === 'RL'; - var levelGap = 210; - var itemGap = 120; - var margin = 45; - var maxX = margin; - var maxY = margin; - - Object.keys(levels).forEach(function (levelKey) { - var level = Number(levelKey); - levels[levelKey].forEach(function (node, index) { - node.width = textWidth(node.label, node.shape === 'diamond' ? 112 : 96); - node.height = node.shape === 'diamond' ? 76 : 52; - node.x = margin + (leftToRight ? level * levelGap : index * levelGap); - node.y = margin + (leftToRight ? index * itemGap : level * itemGap); - maxX = Math.max(maxX, node.x + node.width); - maxY = Math.max(maxY, node.y + node.height); - }); - }); - - graph.routeRight = maxX + 80; - graph.width = Math.ceil(maxX + margin + 220); - graph.height = Math.ceil(maxY + margin); - graph.nodesById = nodesById; - return graph; - } - - function nodeCenter(node) { - return { x: node.x + node.width / 2, y: node.y + node.height / 2 }; - } - - function edgeAnchor(node, otherNode) { - var center = nodeCenter(node); - var otherCenter = nodeCenter(otherNode); - var dx = otherCenter.x - center.x; - var dy = otherCenter.y - center.y; - var horizontal = Math.abs(dx) / node.width > Math.abs(dy) / node.height; - - if (horizontal) { - return { x: center.x + (dx >= 0 ? node.width / 2 : -node.width / 2), y: center.y }; - } - return { x: center.x, y: center.y + (dy >= 0 ? node.height / 2 : -node.height / 2) }; - } - - function renderFlowNode(node) { - var cx = node.x + node.width / 2; - var cy = node.y + node.height / 2; - var shape; - if (node.shape === 'diamond') { - shape = ''; - } else { - shape = ''; - } - return '' + shape + - '' + escapeXml(node.label) + ''; - } - - function renderFlowEdge(edge, graph) { - var fromNode = graph.nodesById[edge.from]; - var toNode = graph.nodesById[edge.to]; - var from = edgeAnchor(fromNode, toNode); - var to = edgeAnchor(toNode, fromNode); - var backward = to.y < from.y - 5 || edge.from === edge.to; - var label = ''; - if (edge.label) { - var dx = backward ? 0 : to.x - from.x; - var dy = backward ? to.y - from.y : to.y - from.y; - var length = Math.max(Math.sqrt(dx * dx + dy * dy), 1); - var labelX = backward ? graph.routeRight + 10 : (from.x + to.x) / 2 + (-dy / length) * 18; - var labelY = (from.y + to.y) / 2 + (backward ? 0 : (dx / length) * 18); - var labelWidth = Math.max(34, edge.label.length * 8 + 14); - label = '' + - '' + escapeXml(edge.label) + ''; - } - if (backward) { - var routeX = graph.routeRight; - return '' + label + ''; - } - return '' + label + ''; - } - - function renderFlowchart(lines) { - var graph = layoutFlowchart(parseFlowchart(lines)); - return renderDocument( - graph.width, - graph.height, - graph.edges.map(function (edge) { return renderFlowEdge(edge, graph); }).join('') + graph.nodes.map(renderFlowNode).join('') - ); - } - - function parseSequence(lines) { - var participants = []; - var participantMap = {}; - var messages = []; - - function ensureParticipant(name, label) { - var id = name.trim(); - if (!participantMap[id]) { - participantMap[id] = { id: id, label: label || id }; - participants.push(participantMap[id]); - } else if (label) { - participantMap[id].label = label; - } - return participantMap[id]; - } - - for (var i = 1; i < lines.length; i++) { - var line = lines[i]; - var participant = line.match(/^(participant|actor)\s+([A-Za-z0-9_]+)(?:\s+as\s+(.+))?$/); - if (participant) { - ensureParticipant(participant[2], participant[3] || participant[2]); - continue; - } - var note = line.match(/^Note\s+(?:over|right of|left of)\s+([A-Za-z0-9_, ]+)\s*:\s*(.+)$/); - if (note) { - messages.push({ type: 'note', target: note[1].split(',')[0].trim(), text: note[2] }); - ensureParticipant(note[1].split(',')[0].trim()); - continue; - } - var message = line.match(/^([A-Za-z0-9_]+)\s*[-=.]+>>?\s*([A-Za-z0-9_]+)\s*:\s*(.+)$/); - if (message) { - ensureParticipant(message[1]); - ensureParticipant(message[2]); - messages.push({ type: 'message', from: message[1], to: message[2], text: message[3], dashed: line.indexOf('--') !== -1 }); - } - } - return { participants: participants, messages: messages }; - } - - function renderSequence(lines) { - var diagram = parseSequence(lines); - var margin = 45; - var headerY = 35; - var laneGap = 180; - var step = 68; - var width = Math.max(320, margin * 2 + Math.max(1, diagram.participants.length - 1) * laneGap + 120); - var height = 115 + Math.max(1, diagram.messages.length) * step; - var positions = {}; - var body = ''; - - diagram.participants.forEach(function (participant, index) { - var x = margin + 60 + index * laneGap; - positions[participant.id] = x; - var boxWidth = textWidth(participant.label, 100); - body += '' + - '' + escapeXml(participant.label) + '' + - ''; - }); - - diagram.messages.forEach(function (message, index) { - var y = 115 + index * step; - if (message.type === 'note') { - var noteX = positions[message.target] || margin + 60; - var noteWidth = textWidth(message.text, 150); - body += '' + - '' + escapeXml(message.text) + ''; - } else { - var fromX = positions[message.from]; - var toX = positions[message.to]; - var textX = (fromX + toX) / 2; - var dash = message.dashed ? ' stroke-dasharray="6 4"' : ''; - body += '' + - '' + - '' + escapeXml(message.text) + ''; - } - }); - - return renderDocument(width, height, body); - } - - function parseClass(lines) { - var classes = {}; - var relations = []; - var currentClass = null; - - function ensureClass(name) { - if (!classes[name]) { - classes[name] = { name: name, members: [] }; - } - return classes[name]; - } - - for (var i = 1; i < lines.length; i++) { - var line = lines[i]; - var classStart = line.match(/^class\s+([A-Za-z0-9_]+)(?:\s*\{)?$/); - if (classStart) { - currentClass = ensureClass(classStart[1]); - if (line.indexOf('{') === -1) { - currentClass = null; - } - continue; - } - if (line === '}') { - currentClass = null; - continue; - } - var member = line.match(/^([A-Za-z0-9_]+)\s*:\s*(.+)$/); - if (member) { - ensureClass(member[1]).members.push(member[2]); - continue; - } - var relation = line.match(/^([A-Za-z0-9_]+)\s+[<|*o. -]*--[>|*o. -]*\s+([A-Za-z0-9_]+)(?:\s*:\s*(.+))?$/); - if (relation) { - ensureClass(relation[1]); - ensureClass(relation[2]); - relations.push({ from: relation[1], to: relation[2], label: relation[3] || '' }); - continue; - } - if (currentClass) { - currentClass.members.push(line); - } - } - return { classes: Object.keys(classes).map(function (key) { return classes[key]; }), relations: relations }; - } - - function renderClassDiagram(lines) { - var diagram = parseClass(lines); - var margin = 45; - var rowGap = 160; - var columns = Math.min(2, Math.max(1, diagram.classes.length)); - var maxClassWidth = 170; - diagram.classes.forEach(function (klass) { - maxClassWidth = Math.max(maxClassWidth, textWidth(klass.name, 170)); - klass.members.forEach(function (member) { - maxClassWidth = Math.max(maxClassWidth, textWidth(member, 170)); - }); - }); - var columnGap = 85; - var width = margin * 2 + columns * maxClassWidth + Math.max(0, columns - 1) * columnGap; - var rows = Math.ceil(diagram.classes.length / columns); - var height = margin * 2 + rows * rowGap; - var positions = {}; - var body = ''; - - diagram.classes.forEach(function (klass, index) { - var col = index % columns; - var row = Math.floor(index / columns); - var x = margin + col * (maxClassWidth + columnGap); - var y = margin + row * rowGap; - var boxHeight = 48 + Math.max(1, klass.members.length) * 22; - positions[klass.name] = { x: x, y: y, width: maxClassWidth, height: boxHeight }; - body += '' + - '' + - '' + escapeXml(klass.name) + ''; - if (klass.members.length === 0) { - body += ' '; - } - klass.members.forEach(function (member, memberIndex) { - body += '' + escapeXml(member) + ''; - }); - body += ''; - }); - - diagram.relations.forEach(function (relation) { - var from = positions[relation.from]; - var to = positions[relation.to]; - var x1 = from.x + from.width; - var y1 = from.y + from.height / 2; - var x2 = to.x; - var y2 = to.y + to.height / 2; - if (x2 < x1) { - x1 = from.x + from.width / 2; - y1 = from.y + from.height; - x2 = to.x + to.width / 2; - y2 = to.y; - } - var label = relation.label ? '' + escapeXml(relation.label) + '' : ''; - body = '' + label + body; - }); - - return renderDocument(width, height, body); - } - - function parseState(lines) { - var states = {}; - var edges = []; - - function stateId(name) { - return name === '[*]' ? 'start_end_' + Object.keys(states).length : name.replace(/\s+/g, '_'); - } - - function ensureState(name) { - var label = name.trim(); - var id = label === '[*]' ? stateId(label) : label.replace(/\s+/g, '_'); - if (!states[id]) { - states[id] = { id: id, label: label, terminal: label === '[*]' }; - } - return states[id]; - } - - for (var i = 1; i < lines.length; i++) { - var line = lines[i]; - var edge = line.match(/^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.+))?$/); - if (edge) { - var from = ensureState(edge[1].trim()); - var to = ensureState(edge[2].trim()); - edges.push({ from: from.id, to: to.id, label: edge[3] || '' }); - } else if (line.indexOf('state ') === 0) { - ensureState(line.replace(/^state\s+/, '').trim()); - } else { - ensureState(line); - } - } - return { nodes: Object.keys(states).map(function (key) { return states[key]; }), edges: edges, direction: 'TD' }; - } - - function renderStateNode(node) { - if (node.terminal) { - return ''; - } - node.width = node.width || textWidth(node.label, 110); - node.height = node.height || 48; - return '' + - '' + escapeXml(node.label) + ''; - } - - function renderStateDiagram(lines) { - var graph = parseState(lines); - graph.nodes.forEach(function (node) { - node.width = node.terminal ? 36 : textWidth(node.label, 110); - node.height = node.terminal ? 36 : 48; - node.shape = 'rect'; - }); - graph = layoutFlowchart(graph); - graph.nodesById = {}; - graph.nodes.forEach(function (node) { graph.nodesById[node.id] = node; }); - return renderDocument( - graph.width, - graph.height, - graph.edges.map(function (edge) { return renderFlowEdge(edge, graph); }).join('') + graph.nodes.map(renderStateNode).join('') - ); - } - - globalThis.renderMermaidToSvg = function renderMermaidToSvg(definition) { - var lines = normalizeLines(definition); - if (lines.length === 0) { - throw new Error('Mermaid definition is empty'); - } - if (/^(flowchart|graph)\s+/.test(lines[0])) { - return renderFlowchart(lines); - } - if (lines[0] === 'sequenceDiagram') { - return renderSequence(lines); - } - if (lines[0] === 'classDiagram') { - return renderClassDiagram(lines); - } - if (/^stateDiagram(?:-v2)?$/.test(lines[0])) { - return renderStateDiagram(lines); - } - throw new Error('Unsupported Mermaid diagram type: ' + lines[0]); - }; -}()); diff --git a/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java b/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java index 3efcb897..3bc9f1d2 100644 --- a/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java +++ b/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java @@ -11,6 +11,9 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.Permission; import static org.junit.jupiter.api.Assertions.*; @@ -110,4 +113,43 @@ void testMcpCommandWithData() throws Exception { output.contains("error") || output.contains("Issue does not exist")); } -} \ No newline at end of file + @Test + @DisplayName("Should render Mermaid SVG command") + void testMermaidToSvgCommand() throws Exception { + Path output = Files.createTempFile("dmtools-mermaid-", ".svg"); + try { + JobRunner.main(new String[]{ + "mermaid_to_svg", + "flowchart TD; A[Start] --> B[Done]", + "--output", + output.toString() + }); + + assertTrue(outContent.toString().contains(output.toAbsolutePath().normalize().toString())); + assertTrue(Files.readString(output, StandardCharsets.UTF_8).contains(" B[Done]", + "--output", + output.toString() + }); + + assertTrue(outContent.toString().contains(output.toAbsolutePath().normalize().toString())); + byte[] header = Files.readAllBytes(output); + assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, new byte[]{header[0], header[1], header[2], header[3]}); + } finally { + Files.deleteIfExists(output); + } + } + +} diff --git a/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java b/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java deleted file mode 100644 index 0b38ae16..00000000 --- a/dmtools-core/src/test/java/com/github/istin/dmtools/mermaid/MermaidToPngCommandTest.java +++ /dev/null @@ -1,156 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright (c) 2024 EPAM Systems, Inc. - -package com.github.istin.dmtools.mermaid; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class MermaidToPngCommandTest { - - @TempDir - Path tempDir; - - static Stream supportedDiagrams() { - return Stream.of( - new DiagramFixture( - "flowchart", - "flowchart TD; A[Start] --> B{Check}; B -->|Yes| C[Done]; B -->|No| D[Retry]", - "Check" - ), - new DiagramFixture( - "sequence", - """ - sequenceDiagram - participant User - participant API - participant Jira - User->>API: Request status - API->>Jira: Fetch issue - Jira-->>API: Issue payload - API-->>User: Render response - """, - "Request status" - ), - new DiagramFixture( - "class", - """ - classDiagram - class Story - Story : +String key - Story : +Status status - class Blocker - Blocker : +resolve() - Story --> Blocker : blocked by - """, - "Story" - ), - new DiagramFixture( - "state", - """ - stateDiagram-v2 - [*] --> Blocked - Blocked --> Backlog : blockers done - Backlog --> InProgress : sprint starts - InProgress --> Done : accepted - Done --> [*] - """, - "Blocked" - ) - ); - } - - @Test - void renderToSvgReturnsSvgForBasicFlowchart() throws Exception { - String svg = new MermaidToPngRenderer().renderToSvg("flowchart TD; A[Start] --> B[Done]"); - - assertTrue(svg.contains(" B[Done]", outputPath); - - assertEquals(outputPath.toAbsolutePath().normalize(), result); - assertPng(outputPath); - } - - @Test - void commandUsesOutputArgument() throws Exception { - Path outputPath = tempDir.resolve("cli-diagram.png"); - - Path result = MermaidToPngCommand.execute(new String[]{ - "mermaid_to_png", - "flowchart TD; A[Start] --> B[Done]", - "--output", - outputPath.toString() - }); - - assertEquals(outputPath.toAbsolutePath().normalize(), result); - assertTrue(Files.exists(outputPath)); - } - - @Test - void commandRequiresDiagramText() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> MermaidToPngCommand.execute(new String[]{"mermaid_to_png"}) - ); - - assertTrue(exception.getMessage().contains("Usage: dmtools mermaid_to_png")); - } - - @Test - void renderToSvgRejectsUnsupportedDiagramTypes() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new MermaidToPngRenderer().renderToPng("pie title Pets", tempDir.resolve("pie.png")) - ); - - assertTrue(exception.getMessage().contains("Unsupported Mermaid diagram type")); - } - - private void assertPng(Path outputPath) throws Exception { - assertTrue(Files.size(outputPath) > 0); - byte[] header = Files.readAllBytes(outputPath); - assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}, new byte[]{header[0], header[1], header[2], header[3]}); - } - - record DiagramFixture(String name, String definition, String expectedText) { - } -} diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer new file mode 160000 index 00000000..fb279604 --- /dev/null +++ b/dmtools-mermaid-renderer @@ -0,0 +1 @@ +Subproject commit fb279604ce0d8ef09c84117a07d28139d9d8438a diff --git a/settings.gradle b/settings.gradle index daefca37..56069e4a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,3 @@ rootProject.name = 'dmtools-cli' include 'dmtools-core', 'dmtools-mcp-annotations', 'dmtools-annotation-processor' +includeBuild 'dmtools-mermaid-renderer' From 36f64ecf8e72467e2db08c2006763629789f9b83 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 5 May 2026 14:51:29 +0300 Subject: [PATCH 05/14] chore: update mermaid renderer submodule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index fb279604..e6bd4b54 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit fb279604ce0d8ef09c84117a07d28139d9d8438a +Subproject commit e6bd4b543608abe75a66737e1f8ac62f04e089da From 2275b3e748a53a0aa9ab3af3ed95456799b7db71 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 01:12:11 +0300 Subject: [PATCH 06/14] chore: update mermaid renderer submodule to fix user-journey labels and title clipping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index e6bd4b54..1d7e5c25 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit e6bd4b543608abe75a66737e1f8ac62f04e089da +Subproject commit 1d7e5c25b3143767d1593e9d7b6fc1d9d5721fa4 From ae7b75f58357f95d74671a4cd6e86ae40c0adcc0 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 01:48:00 +0300 Subject: [PATCH 07/14] chore: update mermaid renderer submodule - fix state purple dots and bottom padding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index 1d7e5c25..36793831 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit 1d7e5c25b3143767d1593e9d7b6fc1d9d5721fa4 +Subproject commit 3679383155e5eefe973571484325d5b00343291e From 08b99a655c6c14f89f5bcd2e812c1642183a7b64 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 10:31:42 +0300 Subject: [PATCH 08/14] Update dmtools-mermaid-renderer submodule (class bold fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index 36793831..e06d6c33 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit 3679383155e5eefe973571484325d5b00343291e +Subproject commit e06d6c33e7b52ecf29b77b4479bbc77c71a346d3 From 217245de6a2ca4dbaeccaa087c7564b4747cb88e Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 10:56:56 +0300 Subject: [PATCH 09/14] Update dmtools-mermaid-renderer (quadrant hanging baseline fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index e06d6c33..d3b57803 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit e06d6c33e7b52ecf29b77b4479bbc77c71a346d3 +Subproject commit d3b5780320e1d1e43de5f639a3c419015e3e3f2d From 821f0c3a869686931c436d3f41492174bd152c7c Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 11:08:14 +0300 Subject: [PATCH 10/14] Update dmtools-mermaid-renderer submodule: user-journey text wrapping fix 'Runs dmtools mermaid_to_png' now wraps to 2 lines matching Playwright output. 95 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index d3b57803..66126b7a 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit d3b5780320e1d1e43de5f639a3c419015e3e3f2d +Subproject commit 66126b7ae658dd7ff794f489f7c6ee6fb7197a9d From f829feafe45a9ab69ba26237565c2cdc095f38d3 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 7 May 2026 18:41:56 +0300 Subject: [PATCH 11/14] Address PR #175 Copilot review: conditional includeBuild, robust tests, mermaid skill docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.gradle: make includeBuild 'dmtools-mermaid-renderer' conditional on directory existence so plain clones without submodule init don't fail at settings evaluation - JobRunnerMcpIntegrationTest: guard mermaid_to_svg and mermaid_to_png tests with ExceptionInInitializerError/NoClassDefFoundError catch → assumeTrue(false, ...) so the test is skipped rather than failing in CI environments where the renderer submodule is not initialised (consistent with all other tests in the class) - dmtools-ai-docs/references/mcp-tools/mermaid-tools.md: add mermaid_to_svg and mermaid_to_png tool documentation (total tools: 3 → 5) with full parameter, example CLI, and JS agent usage sections - dmtools-ai-docs/per-skill-packages/dmtools-mermaid.md: new skill package doc covering headless SVG/PNG rendering via GraalJS + Batik, all 26 supported diagram types, config, security notes, and linkbacks - dmtools-ai-docs/per-skill-packages/index.md: add dmtools-mermaid row and link Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../per-skill-packages/dmtools-mermaid.md | 96 ++++++++++ dmtools-ai-docs/per-skill-packages/index.md | 2 + .../references/mcp-tools/mermaid-tools.md | 179 ++++++++++++++++++ .../job/JobRunnerMcpIntegrationTest.java | 11 ++ settings.gradle | 5 +- 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 dmtools-ai-docs/per-skill-packages/dmtools-mermaid.md diff --git a/dmtools-ai-docs/per-skill-packages/dmtools-mermaid.md b/dmtools-ai-docs/per-skill-packages/dmtools-mermaid.md new file mode 100644 index 00000000..d1e0f5e5 --- /dev/null +++ b/dmtools-ai-docs/per-skill-packages/dmtools-mermaid.md @@ -0,0 +1,96 @@ +# dmtools-mermaid + +## Overview + +`dmtools-mermaid` is the DMtools skill for rendering Mermaid diagrams to SVG and PNG files entirely server-side — no Playwright, no browser, no network required. It uses an embedded GraalJS Mermaid engine for SVG generation and Apache Batik for SVG→PNG rasterisation. + +Supports all major Mermaid diagram types: flowchart, sequence, class, state, ER, user-journey, gantt, pie, git, mindmap, kanban, quadrant, requirement, timeline, venn, block, architecture, C4, sankey, radar, xy, treemap, treeview, ishikawa, packet, wardley. + +## Package / Artifact + +- Java package: `com.github.istin.dmtools.mermaid` +- Renderer module: `com.github.istin:dmtools-mermaid-renderer` (submodule `dmtools-mermaid-renderer`) +- Focused slash command: `/dmtools-mermaid` + +## Installer / CLI example + +```bash +curl -fsSL https://github.com/epam/dm.ai/releases/latest/download/skill-install.sh | bash -s -- --skills mermaid +``` + +```bash +bash install.sh --skills mermaid +``` + +## CLI commands + +```bash +# Render to SVG (vector, lossless) +dmtools mermaid_to_svg "flowchart TD; A[Start] --> B[Done]" --output diagram.svg + +# Render to PNG (raster, suitable for embedding in docs/PRs) +dmtools mermaid_to_png "flowchart TD; A[Start] --> B[Done]" --output diagram.png + +# Generate diagrams from Confluence / Jira pages +dmtools mermaid_index_generate confluence '["SPACE/pages/**"]' --storage_path ./diagrams +``` + +## Usage in JavaScript agents + +```javascript +// Render a diagram to SVG or PNG from within a JS agent +const svgPath = mermaid_to_svg("classDiagram\n class User { +String name }", "--output user.svg"); +const pngPath = mermaid_to_png("pie title Breakdown\n \"A\":40\n \"B\":60", "--output pie.png"); +``` + +## Supported diagram types + +| Category | Types | +|----------|-------| +| Flow / Logic | `flowchart`, `sequenceDiagram`, `stateDiagram` | +| Structure | `classDiagram`, `erDiagram`, `requirementDiagram`, `block`, `architecture`, `C4Context` | +| Planning | `gantt`, `timeline`, `kanban`, `journey` (user journey) | +| Data | `pie`, `xychart`, `sankey`, `quadrantChart`, `radar`, `treemap` | +| Knowledge | `mindmap`, `gitGraph`, `venn`, `ishikawa`, `treeview`, `packet`, `wardley` | + +## Configuration + +No external API credentials required — the renderer runs entirely in-process using GraalJS. Only the dmtools JAR and the `dmtools-mermaid-renderer` submodule are needed. + +Ensure the submodule is initialised when building from source: +```bash +git submodule update --init dmtools-mermaid-renderer +``` + +## Compatibility / Supported versions + +- Requires Java 17+ +- GraalJS polyglot engine (bundled via `org.graalvm.polyglot`) +- Apache Batik 1.17+ for PNG rasterisation +- Compatible with current DMtools focused skill releases + +## Security & Permissions + +- No external network calls are made during rendering +- Mermaid source text is processed in-memory — do not pass sensitive data as diagram text if logs are forwarded externally +- Output files are written to the path you specify; ensure the target directory has correct permissions + +## MCP Tools reference + +See [mermaid-tools.md](../references/mcp-tools/mermaid-tools.md) for full parameter documentation of all 5 tools: +- `mermaid_to_svg` +- `mermaid_to_png` +- `mermaid_index_generate` +- `mermaid_index_read` +- `mermaid_index_read_list` + +## Linkbacks + +- [Central installation guide](../references/installation/README.md) +- [Per-skill package index](index.md) +- [Mermaid MCP tools reference](../references/mcp-tools/mermaid-tools.md) + +## Maintainer / Contact + +- Maintainer: DMtools Team +- Support: [github.com/epam/dm.ai/issues](https://github.com/epam/dm.ai/issues) diff --git a/dmtools-ai-docs/per-skill-packages/index.md b/dmtools-ai-docs/per-skill-packages/index.md index a9531f45..e3422d31 100644 --- a/dmtools-ai-docs/per-skill-packages/index.md +++ b/dmtools-ai-docs/per-skill-packages/index.md @@ -13,6 +13,7 @@ This page is the canonical catalogue for the approved focused DMtools skill pack | `dmtools-testrail` | `/dmtools-testrail` | `com.github.istin.dmtools.testrail` | `com.github.istin:dmtools-testrail` | | `dmtools-teams` | `/dmtools-teams` | `com.github.istin.dmtools.microsoft.teams` | `com.github.istin:dmtools-teams` | | `dmtools-report` | `/dmtools-report` | `com.github.istin.dmtools.report` | `com.github.istin:dmtools-report` | +| `dmtools-mermaid` | `/dmtools-mermaid` | `com.github.istin.dmtools.mermaid` | `com.github.istin:dmtools-mermaid-renderer` | ## Child pages @@ -25,3 +26,4 @@ This page is the canonical catalogue for the approved focused DMtools skill pack - [dmtools-testrail](dmtools-testrail.md) - [dmtools-teams](dmtools-teams.md) - [dmtools-report](dmtools-report.md) +- [dmtools-mermaid](dmtools-mermaid.md) diff --git a/dmtools-ai-docs/references/mcp-tools/mermaid-tools.md b/dmtools-ai-docs/references/mcp-tools/mermaid-tools.md index 6c651642..cb8c41c6 100644 --- a/dmtools-ai-docs/references/mcp-tools/mermaid-tools.md +++ b/dmtools-ai-docs/references/mcp-tools/mermaid-tools.md @@ -1,5 +1,184 @@ # MERMAID MCP Tools +**Total Tools**: 5 + +## Quick Reference + +```bash +# List all mermaid tools +dmtools list | jq '.tools[] | select(.name | startswith("mermaid_"))' + +# Render Mermaid text to SVG file +dmtools mermaid_to_svg "flowchart TD; A[Start] --> B[Done]" --output diagram.svg + +# Render Mermaid text to PNG file +dmtools mermaid_to_png "flowchart TD; A[Start] --> B[Done]" --output diagram.png + +# Generate diagrams from Confluence / Jira content +dmtools mermaid_index_generate [arguments] +``` + +## Usage in JavaScript Agents + +```javascript +// Render a diagram to SVG or PNG +const svgPath = mermaid_to_svg("flowchart TD; A --> B", "--output diagram.svg"); +const pngPath = mermaid_to_png("flowchart TD; A --> B", "--output diagram.png"); + +// Index-based helpers +const result = mermaid_index_generate(...); +const result = mermaid_index_read_list(...); +const result = mermaid_index_read(...); +``` + +## Available Tools + +| Tool Name | Description | Parameters | +|-----------|-------------|------------| +| `mermaid_to_svg` | Render a Mermaid diagram definition to an SVG file using the headless GraalJS renderer (no browser required). Supports all major diagram types: flowchart, sequence, class, state, ER, user-journey, gantt, pie, git, mindmap, kanban, quadrant, requirement, timeline, venn, block, architecture, C4, sankey, radar, xy, treemap, treeview, ishikawa, packet, wardley. | `diagram_text` (string, **required**)
`--output` (string, **required**) | +| `mermaid_to_png` | Render a Mermaid diagram definition to a PNG image file using the headless GraalJS + Apache Batik renderer (no browser required). Same diagram-type coverage as `mermaid_to_svg`. | `diagram_text` (string, **required**)
`--output` (string, **required**) | +| `mermaid_index_generate` | Generate Mermaid diagrams from content sources (Confluence or Jira) based on include/exclude patterns. Processes content recursively and stores diagrams in hierarchical file structure. | `integration` (string, **required**)
`include_patterns` (array, **required**)
`exclude_patterns` (array, optional)
`storage_path` (string, **required**)
`custom_fields` (array, optional)
`include_comments` (boolean, optional) | +| `mermaid_index_read` | Read all Mermaid diagram files (.mmd) from storage path recursively. Returns list of diagrams with their paths and content. | `integration` (string, **required**)
`storage_path` (string, **required**) | +| `mermaid_index_read_list` | Read all Mermaid diagram files (.mmd) from storage path recursively. Returns list of ToText objects with paths and content. | `integration` (string, **required**)
`storage_path` (string, **required**) | + +## Detailed Parameter Information + +### `mermaid_to_svg` + +Render a Mermaid diagram definition to an SVG vector-graphics file. Uses the embedded GraalJS Mermaid engine — no Playwright, no browser, no network required. Output is a self-contained SVG suitable for embedding in documents, web pages, or CI artefacts. + +**Parameters:** + +- **`diagram_text`** (string) 🔴 Required + - Full Mermaid diagram source text (any supported diagram type) + - Example: `flowchart TD; A[Start] --> B[Done]` + +- **`--output`** (string) 🔴 Required + - Destination file path for the SVG output + - Example: `./output/diagram.svg` + +**Supported diagram types:** flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, journey, gantt, pie, gitGraph, mindmap, kanban, quadrantChart, requirementDiagram, timeline, venn, block, architecture, C4Context, sankey, radar, xychart, treemap, treeview, ishikawa, packet, wardley + +**Example:** +```bash +dmtools mermaid_to_svg "flowchart TD; A[Start] --> B{Decision} --> C[End]" --output out.svg +``` + +```javascript +const svgFile = mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi", "--output seq.svg"); +``` + +--- + +### `mermaid_to_png` + +Render a Mermaid diagram definition to a PNG raster image. Uses the embedded GraalJS Mermaid engine for SVG generation and Apache Batik for SVG→PNG rasterisation — no Playwright, no browser required. Output is a PNG file at screen resolution (~96 DPI). + +**Parameters:** + +- **`diagram_text`** (string) 🔴 Required + - Full Mermaid diagram source text (any supported diagram type) + - Example: `flowchart TD; A[Start] --> B[Done]` + +- **`--output`** (string) 🔴 Required + - Destination file path for the PNG output + - Example: `./output/diagram.png` + +**Example:** +```bash +dmtools mermaid_to_png "classDiagram\n class Animal { +String name\n +speak() }" --output class.png +``` + +```javascript +const pngFile = mermaid_to_png("pie title Pet adoption\n \"Dogs\":45\n \"Cats\":30", "--output pie.png"); +``` + +--- + +### `mermaid_index_generate` + +Generate Mermaid diagrams from content sources (Confluence or Jira) based on include/exclude patterns. Processes content recursively and stores diagrams in hierarchical file structure. + +**Parameters:** + +- **`integration`** (string) 🔴 Required + - Integration type: 'confluence', 'jira', or 'jira_xray' + - Example: `confluence` + +- **`include_patterns`** (array) 🔴 Required + - Array of include patterns. For Confluence: ["SPACE/pages/PAGE_ID/PAGE_NAME/**"]. For Jira: ["JQL query"] + - Example: `["YOUR_SPACE/pages/PAGE_ID/Templates/**"]` + +- **`exclude_patterns`** (array) ⚪ Optional + - Optional array of exclude patterns to filter out specific content (not used for Jira) + - Example: `[]` + +- **`storage_path`** (string) 🔴 Required + - Base path for storing generated diagrams + - Example: `./mermaid-diagrams` + +- **`custom_fields`** (array) ⚪ Optional + - Optional array of custom field names to include in content (only for Jira integrations) + - Example: `["summary", "description", "customfield_10001"]` + +- **`include_comments`** (boolean) ⚪ Optional + - Whether to include comments in content (only for Jira integrations, default: false) + - Example: `false` + +**Example:** +```bash +dmtools mermaid_index_generate "confluence" '["MYSPACE/pages/123/Templates/**"]' --storage_path ./diagrams +``` + +```javascript +const result = mermaid_index_generate("integration", "include_patterns"); +``` + +--- + +### `mermaid_index_read` + +Read all Mermaid diagram files (.mmd) from storage path recursively. Returns list of diagrams with their paths and content. + +**Parameters:** + +- **`integration`** (string) 🔴 Required + - Integration type (currently only 'confluence' is supported) + - Example: `confluence` + +- **`storage_path`** (string) 🔴 Required + - Base path where diagrams are stored + - Example: `./mermaid-diagrams` + +**Example:** +```bash +dmtools mermaid_index_read "confluence" "./mermaid-diagrams" +``` + +--- + +### `mermaid_index_read_list` + +Read all Mermaid diagram files (.mmd) from storage path recursively. Returns list of ToText objects with paths and content. + +**Parameters:** + +- **`integration`** (string) 🔴 Required + - Integration type (currently only 'confluence' is supported) + - Example: `confluence` + +- **`storage_path`** (string) 🔴 Required + - Base path where diagrams are stored + - Example: `./mermaid-diagrams` + +**Example:** +```bash +dmtools mermaid_index_read_list "confluence" "./mermaid-diagrams" +``` + +--- + + **Total Tools**: 3 ## Quick Reference diff --git a/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java b/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java index 3bc9f1d2..ade3ea2f 100644 --- a/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java +++ b/dmtools-core/src/test/java/com/github/istin/dmtools/job/JobRunnerMcpIntegrationTest.java @@ -127,6 +127,12 @@ void testMermaidToSvgCommand() throws Exception { assertTrue(outContent.toString().contains(output.toAbsolutePath().normalize().toString())); assertTrue(Files.readString(output, StandardCharsets.UTF_8).contains(" Date: Thu, 7 May 2026 21:16:09 +0300 Subject: [PATCH 12/14] Update dmtools-mermaid-renderer: fix multi-row node width for flowcharts with line breaks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index 66126b7a..547187e1 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit 66126b7ae658dd7ff794f489f7c6ee6fb7197a9d +Subproject commit 547187e18199e1eb83518b8e848a5675a74faee7 From 50e3a34adb2e9f8c69864b029044527ab076b5d3 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 8 May 2026 00:00:52 +0300 Subject: [PATCH 13/14] chore: update mermaid renderer submodule - emoji font support and SVG Affinity fixes - NotoEmoji-Regular.ttf bundled (monochrome, all emoji ranges merged) - Emoji text split into tspan runs with explicit font-family for Batik - text-anchor inlined as presentation attribute (Affinity compatibility) - Redundant parent text y removed for row-positioned labels - SVG file output now uses normalized pipeline (no foreignObject/filter) - 97 unit tests passing (up from 95) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dmtools-mermaid-renderer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dmtools-mermaid-renderer b/dmtools-mermaid-renderer index 547187e1..3a1a8686 160000 --- a/dmtools-mermaid-renderer +++ b/dmtools-mermaid-renderer @@ -1 +1 @@ -Subproject commit 547187e18199e1eb83518b8e848a5675a74faee7 +Subproject commit 3a1a86862d95daea34c04b59f35c797c46305396 From 2e78f4a2329b4887531b0e0fb5d0bad1615d8ed9 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 8 May 2026 00:03:05 +0300 Subject: [PATCH 14/14] fix: checkout submodules in CI so mermaid renderer composite build works Without submodules: recursive, the dmtools-mermaid-renderer directory is empty. The conditional includeBuild in settings.gradle skips it, and Gradle falls back to resolving the Maven artifact from GitHub Packages (which returns 401 in PR context). Checking out submodules lets the composite build supply the dependency without network auth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/unit-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b4999fed..27de2a0a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Set up Java 17 uses: actions/setup-java@v4