diff --git a/mermaid/package-lock.json b/mermaid/package-lock.json
index ff66bf4b4..3bcf19684 100644
--- a/mermaid/package-lock.json
+++ b/mermaid/package-lock.json
@@ -2812,6 +2812,12 @@
"find-up": "^2.1.0"
}
},
+ "pngjs": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
+ "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
+ "dev": true
+ },
"prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
diff --git a/mermaid/package.json b/mermaid/package.json
index 49a04667f..6c1cf58ae 100644
--- a/mermaid/package.json
+++ b/mermaid/package.json
@@ -26,6 +26,7 @@
"chai": "^4.3.4",
"dirty-chai": "^2.0.1",
"mocha": "^9.1.2",
+ "pngjs": "^6.0.0",
"standard": "16.0.4"
}
}
diff --git a/mermaid/src/index.js b/mermaid/src/index.js
index e0202f47f..eebe207a8 100644
--- a/mermaid/src/index.js
+++ b/mermaid/src/index.js
@@ -1,4 +1,4 @@
-const Worker = require('./worker')
+const { Worker, SyntaxError } = require('./worker')
const Task = require('./task')
const instance = require('./browser-instance')
const micro = require('micro')
@@ -11,18 +11,27 @@ const micro = require('micro')
const server = micro(async (req, res) => {
// TODO: add a /_status route (return mermaid version)
// TODO: read the diagram source as plain text
- const diagramSource = await micro.text(req, { limit: '1mb', encoding: 'utf8' })
- if (diagramSource) {
- try {
- const svg = await worker.convert(new Task(diagramSource))
- res.setHeader('Content-Type', 'image/svg+xml')
- return micro.send(res, 200, svg)
- } catch (e) {
- console.log('e', e)
- return micro.send(res, 400, 'Unable to convert the diagram')
+ const outputType = req.url.match(/\/(png|svg)$/)?.[1]
+ if (outputType) {
+ const diagramSource = await micro.text(req, { limit: '1mb', encoding: 'utf8' })
+ if (diagramSource) {
+ try {
+ const isPng = outputType === 'png'
+ const output = await worker.convert(new Task(diagramSource, isPng))
+ res.setHeader('Content-Type', isPng ? 'image/png' : 'image/svg+xml')
+ return micro.send(res, 200, output)
+ } catch (e) {
+ if (e instanceof SyntaxError) {
+ return micro.send(res, 400, e.message)
+ } else {
+ console.log('Exception during convert', e)
+ return micro.send(res, 500, 'An error occurred while converting the diagram')
+ }
+ }
}
+ return micro.send(res, 400, 'Body must not be empty.')
}
- micro.send(res, 400, 'Body must not be empty.')
+ return micro.send(res, 400, 'Available endpoints are /svg and /png.')
})
server.listen(8002)
})().catch(error => {
diff --git a/mermaid/src/task.js b/mermaid/src/task.js
index 1d1d2f3a5..e0e583e91 100644
--- a/mermaid/src/task.js
+++ b/mermaid/src/task.js
@@ -1,6 +1,7 @@
class Task {
- constructor (source) {
+ constructor (source, isPng = false) {
this.source = source
+ this.isPng = isPng
this.mermaidConfig = {
theme: 'default',
class: {
diff --git a/mermaid/src/worker.js b/mermaid/src/worker.js
index 40986cc9f..96556cda4 100644
--- a/mermaid/src/worker.js
+++ b/mermaid/src/worker.js
@@ -2,6 +2,12 @@
const path = require('path')
const puppeteer = require('puppeteer')
+class SyntaxError extends Error {
+ constructor () {
+ super('Syntax error in graph')
+ }
+}
+
class Worker {
constructor (browserInstance) {
this.browserWSEndpoint = browserInstance.wsEndpoint()
@@ -23,17 +29,28 @@ class Worker {
window.mermaid.initialize(mermaidConfig)
window.mermaid.init(undefined, container)
}, task.source, task.mermaidConfig)
- return await page.$eval('#container', container => {
- const xmlSerializer = new XMLSerializer()
- const nodes = []
- for (let i = 0; i < container.childNodes.length; i++) {
- nodes.push(xmlSerializer.serializeToString(container.childNodes[i]))
- }
- return nodes.join('')
- })
- } catch (e) {
- console.error('Unable to convert the diagram', e)
- throw e
+
+ // diagrams are directly under #containers, while the SVG generated upon syntax error is wrapped in a div
+ const svg = await page.$('#container > svg')
+ if (!svg) {
+ throw new SyntaxError()
+ }
+
+ if (task.isPng) {
+ return await svg.screenshot({
+ type: 'png',
+ omitBackground: true
+ })
+ } else {
+ return await page.$eval('#container', container => {
+ const xmlSerializer = new XMLSerializer()
+ const nodes = []
+ for (let i = 0; i < container.childNodes.length; i++) {
+ nodes.push(xmlSerializer.serializeToString(container.childNodes[i]))
+ }
+ return nodes.join('')
+ })
+ }
} finally {
try {
await page.close()
@@ -49,4 +66,7 @@ class Worker {
}
}
-module.exports = Worker
+module.exports = {
+ Worker,
+ SyntaxError
+}
diff --git a/mermaid/tests/test.js b/mermaid/tests/test.js
index 200fc340a..9acab62b5 100644
--- a/mermaid/tests/test.js
+++ b/mermaid/tests/test.js
@@ -7,24 +7,47 @@ const expect = chai.expect
const dirtyChai = require('dirty-chai')
chai.use(dirtyChai)
-const Worker = require('../src/worker.js')
+const PNG = require('pngjs').PNG
+
+const { Worker, SyntaxError } = require('../src/worker.js')
const Task = require('../src/task.js')
-const tests = [
+const svgTests = [
{content: 'Hello
World'},
{content: 'Hello
World'},
{content: 'Hello
World'},
{content: 'Hello
World'},
]
+const pngTests = [
+ {type: 'graph', width: 178, height: 168, content: `graph TD
+ A --> B
+ C{{test}} --> D[(db)]
+ A --> D`},
+ {type: 'sequence', width: 504, height: 387, content: `sequenceDiagram
+ Alice->>+John: Hello John, how are you?
+ Alice->>+John: John, can you hear me?
+ John-->>-Alice: Hi Alice, I can hear you!
+ John-->>-Alice: I feel great!`}
+]
+
+const invalidSyntaxTests = [
+ {endpoint: 'svg', isPng: false},
+ {endpoint: 'png', isPng: true}
+]
+
+async function getBrowser() {
+ return puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']})
+}
+
describe('#convert', function () {
// Puppeteer can take some time to start
this.timeout(10000)
- tests.forEach((testCase) => {
+ svgTests.forEach((testCase) => {
it(`should return an XML compatible SVG with content: ${testCase.content}`, async function () {
- const browser = await puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox']})
+ const browser = await getBrowser()
try {
const worker = new Worker(browser)
const result = await worker.convert(new Task(`graph TD
@@ -35,4 +58,35 @@ describe('#convert', function () {
}
})
})
+
+ pngTests.forEach((testCase) => {
+ it(`should return a PNG for type ${testCase.type} with height=${testCase.height}, width=${testCase.width}`, async function () {
+ const browser = await getBrowser()
+ try {
+ const worker = new Worker(browser)
+ const result = await worker.convert(new Task(testCase.content, true));
+
+ const image = PNG.sync.read(result); // this will fail on invalid image
+
+ expect(image.width).to.be.closeTo(testCase.width, 20)
+ expect(image.height).to.be.closeTo(testCase.height, 50) // padding can make it vary more
+ } finally {
+ await browser.close()
+ }
+ })
+ })
+
+ invalidSyntaxTests.forEach((testCase) => {
+ it(`should throw syntax error in endpoint /${testCase.endpoint}`, async function () {
+ const browser = await getBrowser()
+ try {
+ await new Worker(browser).convert(new Task('not a valid mermaid code', testCase.isPng))
+ chai.assert.fail('No error was thrown')
+ } catch (error) {
+ expect(error).to.be.an.instanceof(SyntaxError)
+ } finally {
+ await browser.close()
+ }
+ })
+ })
})
diff --git a/server/src/main/java/io/kroki/server/service/Mermaid.java b/server/src/main/java/io/kroki/server/service/Mermaid.java
index 1574d4355..b0feaed9a 100644
--- a/server/src/main/java/io/kroki/server/service/Mermaid.java
+++ b/server/src/main/java/io/kroki/server/service/Mermaid.java
@@ -13,11 +13,14 @@
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Mermaid implements DiagramService {
+ private static final List SUPPORTED_FORMATS = Arrays.asList(FileFormat.PNG, FileFormat.SVG);
+
private final WebClient client;
private final String host;
private final int port;
@@ -37,7 +40,7 @@ public String decode(String encoded) throws DecodeException {
@Override
public List getSupportedFormats() {
- return Collections.singletonList(FileFormat.SVG);
+ return SUPPORTED_FORMATS;
}
@Override