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