Skip to content

resolves #507 add png support for Mermaid #951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 27, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions mermaid/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions mermaid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
31 changes: 20 additions & 11 deletions mermaid/src/index.js
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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 => {
Expand Down
3 changes: 2 additions & 1 deletion mermaid/src/task.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Task {
constructor (source) {
constructor (source, isPng = false) {
this.source = source
this.isPng = isPng
this.mermaidConfig = {
theme: 'default',
class: {
Expand Down
44 changes: 32 additions & 12 deletions mermaid/src/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -49,4 +66,7 @@ class Worker {
}
}

module.exports = Worker
module.exports = {
Worker,
SyntaxError
}
62 changes: 58 additions & 4 deletions mermaid/tests/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>World'},
{content: 'Hello<br>World'},
{content: 'Hello<br >World'},
{content: 'Hello<br />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
Expand All @@ -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()
}
})
})
})
5 changes: 4 additions & 1 deletion server/src/main/java/io/kroki/server/service/Mermaid.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileFormat> SUPPORTED_FORMATS = Arrays.asList(FileFormat.PNG, FileFormat.SVG);

private final WebClient client;
private final String host;
private final int port;
Expand All @@ -37,7 +40,7 @@ public String decode(String encoded) throws DecodeException {

@Override
public List<FileFormat> getSupportedFormats() {
return Collections.singletonList(FileFormat.SVG);
return SUPPORTED_FORMATS;
}

@Override
Expand Down