Steph Ango (kepano), of Obsidian, has published Pele. It's a Javascript library to render Mermaid diagram syntax to SVG. Obsidian built it for themselves because Mermaid.js was slow, heavy, and hard to theme, and then released it under MIT.
It's a 230 kB gzipped bundle against Mermaid's 1.6 MB, render times in fractions of a millisecond against Mermaid's tens of milliseconds, and zero direct dependencies against Mermaid's twenty-three. It claims coverage of every diagram type in Mermaid 12.1.0, with parsers tested against Mermaid's own parser specs and differential fuzzing on top. Those are their numbers, to be clear. The dependency count is a huge win, and it's verifiable (and verified.)
Mermaid.js has never run cleanly outside a browser1. It needs a DOM to measure text, and it needs the DOM to be real enough that jsdom tends not to be enough. The usual server-side answer has been Puppeteer, which means a headless Chromium process to draw a flowchart. On the JVM the answer has mostly been "don't," or shell out to mermaid-cli, which is Puppeteer wearing a mask wearing a hat with heavy makeup and a lot of squinting.
Pele was built with that constraint in mind. render() is synchronous and returns an SVG string. Text measurement uses canvas in a browser and falls back to a built-in width table if necessary, so there is no DOM requirement at all. Install, call render() from Node with no document global; it returns a flowchart, as one would hope. The shipped dist/pele.global.js is a single IIFE that defines a pele global, and scanning it for host APIs that usually trip up embedded JavaScript engines turns up one reference to document, inside mount(), the browser-only entry point. No window, no setTimeout, no Intl, no TextEncoder.
So I loaded it into GraalJS on Temurin 25. It rendered on the first try.
GraalJS without GraalVM
GraalJS is a set of Maven artifacts, org.graalvm.polyglot:polyglot and org.graalvm.polyglot:js-community, that run on an ordinary JDK like Temurin or, well, any valid JVM after 21.
What you give up on a stock JDK as opposed to GraalVM is the JIT. Without the Graal compiler underneath it, the engine runs in interpreter mode and warns you about it on startup. For a content renderer that is a non-issue, because Pele's starting point is so fast that interpreter mode lands in the single-digit milliseconds. On an Apple-silicon Mac2 with Temurin 25.0.4.1, a six-node flowchart renders in about 1.7 ms warm, reusing one Context, and about 4 ms with a fresh Context for every diagram. Measuring labels in Java, described below, adds nothing I could measure. The first diagram after a start takes about a second: starting the engine and loading the bundle, once.
You can have the JIT on Temurin, nearly. It takes the Graal compiler's jars on the JVM's --upgrade-module-path, -XX:+EnableJVMCI, and a compiler built for exactly your JDK's JVMCI. The newest Graal refused Temurin 25.0.4.1 outright, and the matching release only ran after I overrode a configuration check. Warm, it was about seven times faster, at a quarter of a millisecond a diagram, but it took about a thousand renders to get there, and it was slower than the interpreter until then. This is where load evaluation factors in: that’s a thousand renders of worse to reach better, so the interpreter stays unless you're using rendering a lot.
import org.graalvm.polyglot.*;
import java.nio.file.*;
/**
* Draws Mermaid diagrams as SVG with Pele, in GraalJS, in the JVM. The class
* does not use a browser, a DOM or Node.
*
* <p>One instance holds one GraalJS context. A context is not thread-safe.
* Thus {@link #svg} is synchronized, and one diagram is drawn at a time. For
* more throughput, make one instance for each thread.
*
* <p>Close the instance when you no longer need it. This releases the context.
*/
public final class Pele implements AutoCloseable {
/** The GraalJS context that holds Pele. */
private final Context ctx;
/** Pele's {@code render} function, from the context. */
private final Value render;
/**
* Loads Pele into a new GraalJS context.
*
* <p>The script can use only host members that have
* {@code @HostAccess.Export}. It cannot use other Java classes or
* objects. On a JDK without the Graal compiler, GraalJS runs in
* interpreter mode. The constructor turns off the warning about this,
* because interpreter mode is fast enough here.
*
* <p>The first load takes about one second. Make the instance one time,
* and use it again.
*
* @param bundle the path of {@code pele.global.js}, from the {@code pele}
* npm package. This file defines the global {@code pele} object.
* @throws java.io.IOException if the method cannot read the file.
*/
public Pele(Path bundle) throws java.io.IOException {
ctx = Context.newBuilder("js")
.allowHostAccess(HostAccess.EXPLICIT)
.option("engine.WarnInterpreterOnly", "false")
.build();
ctx.eval(Source.newBuilder("js", bundle.toFile()).build());
render = ctx.eval("js", "pele.render");
}
/**
* Draws one diagram.
*
* <p>The method uses Pele's default options. Links and images in the
* diagram are on. Pele does not stop a slow diagram. If the source can
* come from users, give a time limit from another thread.
*
* @param mermaid the Mermaid source of one diagram.
* @return the SVG of the diagram, as a string.
* @throws org.graalvm.polyglot.PolyglotException if Pele cannot draw the
* diagram, for example because of a syntax error. The message tells
* where the error is.
*/
public synchronized String svg(String mermaid) {
return render.execute(mermaid).getMember("svg").asString();
}
/** Closes the GraalJS context. Do not use the instance after this. */
@Override public void close() { ctx.close(); }
}
There's one context per instance, one diagram at a time, and the time limit is up to the caller because Pele has none. For this site, I keep one Engine and make a fresh Context on it for each diagram, so nothing one author’s diagram does is still there for the next, and a watchdog closes any context that runs past five seconds. Where diagrams actually land, in markdown pipelines, is a fenced code block with an info string of mermaid, and the renderer hook is an afternoon's effort.
Measuring on the JVM
Pele sizes nodes by measuring label text, and RenderOptions accepts a TextMeasurer with one method, width(text, size, style), where style is a bit set of bold, italic, and monospace. In a browser the default measurer is a canvas, so it measures whatever font you actually use. Outside a browser the default is a table, and the table wants attention. It holds Arial advance widths for the 95 printable ASCII characters, regular and bold. Italic is ignored. Monospace is a flat 600 units per glyph. Anything outside ASCII is a flat 580 units, or 1000 for CJK ranges. There is no kerning. It is an honest estimate, exactly right for Arial and approximately right for everything else.
The JVM has java.awt.font.TextLayout, which gives you a fractional advance for a string in a real font, with kerning and ligatures if you ask. Expose a method with @HostAccess.Export, pass the object in the options, and Pele calls back into Java for every label.
Diagram source
sequenceDiagram
participant S as bytecode.news
participant G as GraalJS
S->>G: render(mermaid, {measurer})
G->>S: width(text, size, style)
S-->>G: TextLayout advance
G-->>S: {svg, width, height}
On this site, measurements use IBM Plex Sans and Plex Mono, bundled as TTFs, and the SVG names them explicitly. I measured one-node flowcharts both ways, as box widths at 16 px, padding included, so the labels' own differences are larger than these:
| Label | Pele's table | IBM Plex |
|---|---|---|
Publish | 105 | 106 |
Reviewed and approved | 225 | 224 |
WAVE AVAILABLE | 190 | 181 |
minimum illuminati | 184 | 191 |
Café déjà vu, naïve façade | 250 | 238 |
Ünïcödé Größe | 165 | 160 |
Plex's own widths, kerned, make "WAVE AVAILABLE" nine pixels narrower, and the accented line twelve, where the table gives every character past ASCII the same flat width. As a check, I rendered the same diagrams in Chromium with the same Plex files, where Pele measures through canvas. Every one of those labels came out at exactly the server’s width. Those are the labels that would otherwise end up touching the edges of their boxes, and the measurer is what fixes them.
/**
* The four faces of the diagram's text font, by Pele's style bits:
* regular, bold, italic, bold italic.
*/
private final Font[] sansFaces;
/**
* The four faces of the diagram's monospace font,
* in the same order.
*/
private final Font[] monoFaces;
/**
* Java's logical sans-serif font. Unlike a font
* from a file, it falls back for missing glyphs.
*/
private final Font logicalSans = new Font(Font.SANS_SERIF, Font.PLAIN, 1);
/** Java's logical monospace font, for the same purpose. */
private final Font logicalMono = new Font(Font.MONOSPACED, Font.PLAIN, 1);
/**
* Antialiased, with fractional metrics, so that
* advances are subpixel, as a browser's are.
*/
private final FontRenderContext frc =
new FontRenderContext(null, true, true);
/**
* Loads one family from four files.
*
* @param dir the directory of the files.
* @param family the file name before the face, for example {@code IBMPlexSans}.
* @return the faces, by Pele's style bits. Kerning and ligatures are on.
*/
private static Font[] loadFaces(Path dir, String family)
throws IOException, FontFormatException {
String[] faces = {"Regular", "Bold", "Italic", "BoldItalic"};
Map<TextAttribute, Object> attrs = Map.of(
TextAttribute.KERNING, TextAttribute.KERNING_ON,
TextAttribute.LIGATURES, TextAttribute.LIGATURES_ON);
Font[] result = new Font[4];
for (int i = 0; i < 4; i++) {
File file = dir.resolve(family + "-" + faces[i] + ".ttf")
.toFile();
result[i] = Font.createFont(
Font.TRUETYPE_FONT,
file
).deriveFont(attrs);
}
return result;
}
public PlexMeasurer(Path fontDir)
throws IOException, FontFormatException {
sansFaces = loadFaces(fontDir, "IBMPlexSans");
monoFaces = loadFaces(fontDir, "IBMPlexMono");
}
/**
* Measures the width of one diagram label. Pele calls this method for each
* label, through GraalJS, to set the size of the box around the label.
*
* <p>The method uses the diagram fonts, IBM Plex Sans and IBM Plex Mono.
* The SVG names these fonts. Thus the browser shows the label in the font
* that this method measured.
*
* <p>If the face has a glyph for each character, the method measures the
* full text in the face. If the face does not have a glyph for a character,
* the method gives the text to {@link #advance}. A font from a file does not
* use other fonts for missing glyphs. A browser does.
*
* <p>The method gets the style of the fallback font from {@code style}. It
* does not get the style from the face. A font from {@code Font.createFont}
* always reports {@code Font.PLAIN}, also when the file is bold or italic.
*
* @param text the label text. It can be empty.
* @param size the font size, in pixels.
* @param style Pele's style bits: 1 is bold, 2 is italic, 4 is monospace.
* Pele can set more than one bit.
* @return the width of the text, in pixels. The width of empty text is 0.
*/
@HostAccess.Export
public double width(String text, double size, int style) {
if (text.isEmpty()) {
return 0;
}
boolean mono = (style & 4) != 0;
Font face = (mono ? monoFaces : sansFaces)[style & 3]
.deriveFont((float) size);
if (face.canDisplayUpTo(text) < 0) {
return new TextLayout(text, face, frc).getAdvance();
}
int awtStyle = ((style & 1) != 0 ? Font.BOLD : 0)
| ((style & 2) != 0 ? Font.ITALIC : 0);
Font fallback = (mono ? logicalMono : logicalSans)
.deriveFont(awtStyle, (float) size);
return advance(text, List.of(face, fallback), size);
}
/**
* Measures text one character at a time, as a CSS font stack does.
*
* <p>For each character, the method uses the first font in {@code fonts} that
* has a glyph for it. It measures each run of characters in one font as one
* string. Thus kerning in the run stays correct.
*
* <p>If no font has a glyph for a character, the method does not measure it.
* The glyph for a missing character is a box, and its width is wrong. The
* method uses the estimate from Pele's own table instead: 1 em for a
* full-width character, and 0.58 em for all other characters.
*
* @param text the text to measure. It must not be empty.
* @param fonts the fonts to try, in order. Each font has the correct size.
* @param size the font size, in pixels. The estimate uses it as 1 em.
* @return the width of the text, in pixels.
*/
private double advance(String text, List<Font> fonts, double size) {
double total = 0;
StringBuilder run = new StringBuilder();
Font runFont = null;
for (int i = 0; i < text.length(); ) {
int point = text.codePointAt(i);
i += Character.charCount(point);
Font font = null;
for (Font candidate : fonts) {
if (candidate.canDisplay(point)) {
font = candidate;
break;
}
}
if (font != runFont) {
total += measure(run, runFont);
runFont = font;
}
if (font == null) {
total += size * (isWide(point) ? 1.0 : 0.58);
} else {
run.appendCodePoint(point);
}
}
return total + measure(run, runFont);
}
/**
* Measures one run of characters, and then clears the run.
*
* <p>The method clears {@code run}, so that the caller can use the same
* buffer for the next run.
*
* @param run the characters to measure. All of them have glyphs in
* {@code font}. The method clears this buffer.
* @param font the font for the run. It can be {@code null} only when
* {@code run} is empty.
* @return the width of the run, in pixels. The width of an empty run is 0.
*/
private double measure(StringBuilder run, Font font) {
if (run.isEmpty()) {
return 0;
}
double advance = new TextLayout(run.toString(), font, frc)
.getAdvance();
run.setLength(0);
return advance;
}
/**
* Tells if a character is full width. Pele's table gives a full-width
* character a width of 1 em. This method uses the same rule, so that its
* estimates agree with Pele's.
*
* <p>Full-width characters are Han, Hiragana, Katakana, Hangul and
* Bopomofo, CJK punctuation (U+3000 to U+303F), and the half-width and
* full-width forms (U+FF00 to U+FFEF).
*
* @param point the Unicode code point of the character.
* @return {@code true} if the character is full width.
*/
private static boolean isWide(int point) {
return switch (Character.UnicodeScript.of(point)) {
case HAN, HIRAGANA, KATAKANA, HANGUL, BOPOMOFO -> true;
default -> (point >= 0x3000 && point <= 0x303F)
|| (point >= 0xFF00 && point <= 0xFFEF);
};
}
This is a giant blob of code. Ordinarily, I'd give you fully compilable code, preferably with tests, too, but this is already a gargantuan, cyclopic, sesquipedalian code block. Assume you'd wrap it in your own class, probably not called PlexMeasurer, and you'd provide font references for your specific use case, as this is BCN's font selection.
The Javadoc describes what's going on here, but note: five things in Java2D3 can bite you in this.
deriveFont(Font.BOLD) on a font loaded from a file synthesizes bold and synthesizes its metrics with it, so you need to load the regular, bold, italic, and bold-italic faces as four files; that is what the [style & 3] index is for.
Those four faces all say they're plain. Font.createFont() returns a font whose getStyle() is Font.PLAIN, whichever file it read, because a Font's style is what was asked for when it was made, not what's in the file. That is why width() builds the fallback’s style from Pele’s bits and not from the face. Before it did, italic CJK labels measured about three pixels narrow.
Physical fonts do not fall back to other fonts for missing glyphs the way a CSS font stack does; canDisplayUpTo() tells you when you've hit one, and the logical SansSerif font does fall back. advance() switches per character rather than for the whole string, because a label with one odd character would otherwise be measured entirely in the wrong font.
Kerning is off by default in Java2D and on by default in browsers, so derive the faces with TextAttribute.KERNING_ON and build the FontRenderContext with fractional metrics enabled, because canvas measureText returns subpixel advances and you want to agree with it.
And a character that no font on the server has needs an estimate, not a measurement, which is the last branch in advance(). In the Temurin image the only other fonts are DejaVu, which has no CJK, so 漢字かな4 measured as missing-glyph boxes, a fifth too narrow. Those characters get Pele’s own estimate now, an em for CJK, by the rule in isWide().
Three operational rules. Measure with the font the SVG will declare, through the fontFamily and fontFamilyMono options, from a TTF or OTF on the server, since Java2D can't read the woff2 you serve to browsers. Serve that same font to every site that shows the diagrams, under that same family name, and don't let site CSS override --pele-font; Next.js's next/font renames the families it loads, so there the font needs a plain @font-face of its own. And AWT needs fontconfig to load any font at all; the eclipse-temurin Ubuntu images already have it, but a slimmer base image may not.
Node could do this too, with a font library such as fontkit behind the same width(). The JVM has it built in.
All told: Pele's really impressive.
ByteCode News uses Pele now. I looked at Mermaid support for BCN posts earlier this year and passed, partly because posts here regenerate whenever their underlying material updates, and regenerating diagrams through a headless browser on every change was an expense I did not wish to pay. A few milliseconds a diagram, with no browser in the loop, removes that objection. The diagram in this post was rendered the way the post describes.
-
Well, okay, Mermaid has never run cleanly outside of a browser in my opinion, but I think this is a defensible claim, given that it has some pretty hefty requirements like access to a browser's document model.
↩ -
Specifically, an M3 MacBook Pro; if anyone wants to sponsor an M5, give me a call, let's talk! :)
↩ -
I love Java2D. It's actually amazingly capable. Java's ImageIO capabilities are rather limiting for some image types - JPEG2000, BigTiffs, and some other unfortunate image types I get to work with on a daily basis - but for smaller images at normal human sizings, Java2D and ImageIO are a lot more powerful than they get credit for being, in my opinion.
↩ -
This is "kanji and kana," in Japanese, rendered in, well, Kanji and Kana - this text was chosen as a good test of both font renderings. I don't know Japanese, so if this is in error, let's all blame Google, or an LLM, or ... well, anyone but me, except that I accepted it as a viable test.
↩
dreamreal ·
Is this the actual code BCN uses? No, it is not. BCN is written in Kotlin, running on the JVM; this is generally equivalent Java code.