PlantUMLBrowser.java
package net.sourceforge.plantuml.teavm.js.browser;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSExport;
import org.teavm.jso.JSFunctor;
import org.teavm.jso.JSObject;
import org.teavm.jso.dom.html.HTMLDocument;
import org.teavm.jso.dom.html.HTMLElement;
import org.teavm.jso.dom.xml.Element;
import net.sourceforge.plantuml.FileFormat;
import net.sourceforge.plantuml.FileFormatOption;
import net.sourceforge.plantuml.Scale;
import net.sourceforge.plantuml.TitledDiagram;
import net.sourceforge.plantuml.UgDiagram;
import net.sourceforge.plantuml.core.AbstractDiagram;
import net.sourceforge.plantuml.core.Diagram;
import net.sourceforge.plantuml.core.DiagramChromeFactory;
import net.sourceforge.plantuml.klimt.color.ColorMapper;
import net.sourceforge.plantuml.klimt.color.HColor;
import net.sourceforge.plantuml.klimt.color.HColors;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.drawing.hand.UGraphicHandwritten;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.shape.TextBlock;
import net.sourceforge.plantuml.teavm.js.PSystemBuilderJs;
import net.sourceforge.plantuml.teavm.js.StringBounderTeaVM;
import net.sourceforge.plantuml.teavm.js.SvgGraphicsJs;
import net.sourceforge.plantuml.teavm.js.UGraphicJs;
/**
* PlantUML rendering engine for browser environments, compiled to a JavaScript
* ES2015 module via TeaVM.
*
* <h2>Overview</h2>
*
* This class provides a bridge between JavaScript code running in a browser and
* the PlantUML Java rendering engine. The compiled output ({@code plantuml.js})
* is an ES2015 module that exports two functions: {@code render} and
* {@code renderToString}.
*
* <h2>Usage from JavaScript</h2>
*
* <pre>
* <script type="module">
* import { render, renderToString } from './plantuml.js';
*
* // Render directly into a DOM element
* const source = "@startuml\nAlice -> Bob : hello\n@enduml";
* const lines = source.split(/\r\n|\r|\n/);
* render(lines, "diagram-output-id");
*
* // Or get the SVG as a string
* renderToString(lines,
* svg => console.log(svg),
* err => console.error(err)
* );
* </script>
* </pre>
*
* Both functions accept an optional {@code options} object as the last
* argument, e.g. {@code { dark: true }} to enable dark-mode rendering, or
* {@code { maxSvgSize: 9999 } } to raise the maximum SVG width/height (in
* pixels) beyond the default of {@value #DEFAULT_MAX_SVG_SIZE}. Passing
* {@code { maxSvgSize: 0 } } disables the size check entirely.
*
* <h2>Architecture: JavaScript-driven paradigm</h2>
*
* The design follows a "JS-driven" architecture where:
* <ul>
* <li><b>JavaScript handles:</b> UI events, user input, debouncing, line
* splitting, DOM element selection, and overall application flow</li>
* <li><b>Java handles:</b> PlantUML parsing and SVG rendering only</li>
* </ul>
*
* This separation keeps the Java code minimal and allows maximum flexibility
* for web developers to integrate PlantUML however they want.
*
* <h2>No thread, no coroutine</h2>
*
* There is no worker thread, and nothing in the engine suspends (no TeaVM
* coroutine: see tools/teavm-coroutines). What the rendering needs from JavaScript and
* can only get asynchronously (the Viz.js instance, support scripts such as
* {@code emoji.js} or {@code <lib>.min.js}, files of
* {@code PLANTUML_FILE_LOADER}) is handled by rendering again: an
* attempt that misses one is discarded, the resources are loaded with
* JavaScript callbacks, and the diagram is rendered again from its source (see
* {@link PendingResources} and {@link RenderQueue}).
*
* <p>
* The exported functions only queue the request and return: the rendering
* starts from a later JavaScript task ({@code setTimeout}), as it did when a
* worker thread did it, so the callbacks are never called before the function
* returns.
*
* <h2>Order of the answers</h2>
*
* Requests are answered one at a time, oldest first. Two rules decide what
* happens when several are waiting:
* <ul>
* <li>{@code renderToString}: every request is kept and answered, in the order
* received. Each of its callbacks is called exactly once ({@code onSuccess} or
* {@code onError}); a host that renders many diagrams at the same time gets all
* of them back.</li>
* <li>{@code render}: only the latest request is kept, a new one replaces the
* {@code render} request still waiting, including one waiting for its
* resources. This is intentional: when a user is typing, we only care about
* rendering the latest version.</li>
* </ul>
*
* Neither kind drops the other. A callback that throws, or a missing callback,
* does not stop the engine. A request made from a callback (or while a request
* waits for its resources) is queued behind the ones already waiting: it is
* never rendered in the middle of another one.
*
* <p>
* The DOM element of {@code render} is only written with the result of the
* attempt that is kept, so a discarded attempt never shows.
*
* @see net.sourceforge.plantuml.teavm.js.GraphVizEngineJs
*/
public class PlantUMLBrowser {
// ::remove file when JAVA8
// =========================================================================
// Rendering configuration
// =========================================================================
private static final StringBounder STRING_BOUNDER = new StringBounderTeaVM();
/**
* Default maximum width or height (in pixels) before refusing to render.
* Callers may override this per-call via the {@code maxSvgSize} rendering
* option; a value of {@code 0} disables the check entirely. SVG output has
* no raster memory cost, so this default exists only as a sane guard rail,
* not a hard technical ceiling.
*/
private static final int DEFAULT_MAX_SVG_SIZE = 8192;
// =========================================================================
// Requests
// =========================================================================
/** A {@link #render} request: the SVG goes into a DOM element. */
private static final class ElementJob extends RenderQueue.Job {
private final String[] lines;
private final String elementId;
private final boolean darkMode;
private final int maxSvgSize;
private SvgGraphicsJs svg;
private Throwable failure;
private ElementJob(String[] lines, String elementId, boolean darkMode, int maxSvgSize) {
this.lines = lines;
this.elementId = elementId;
this.darkMode = darkMode;
this.maxSvgSize = maxSvgSize;
}
@Override
protected boolean replaces(RenderQueue.Job waiting) {
// "Latest wins": a render request still waiting is dropped, in place.
return waiting instanceof ElementJob;
}
@Override
protected void attempt() {
BrowserLog.reset();
svg = null;
failure = null;
try {
svg = buildSvg(lines, darkMode, maxSvgSize);
} catch (Throwable t) {
failure = t;
}
}
@Override
protected void deliver() {
final HTMLElement out = HTMLDocument.current().getElementById(elementId);
if (out != null) {
if (svg != null) {
removeAllChildren(out);
appendSvgElement(out, svg.getSvgRoot());
} else {
out.setTextContent(describe(failure));
}
}
BrowserLog.jsStatusDuration();
}
@Override
protected void giveUp(String message) {
final HTMLElement out = HTMLDocument.current().getElementById(elementId);
if (out != null)
out.setTextContent(message);
}
}
/** A {@link #renderToString} request: the SVG goes to a callback. */
private static final class CallbackJob extends RenderQueue.Job {
private final String[] lines;
private final StringCallback onSuccess;
private final StringCallback onError;
private final boolean darkMode;
private final int maxSvgSize;
private String svg;
private Throwable failure;
private CallbackJob(String[] lines, StringCallback onSuccess, StringCallback onError, boolean darkMode,
int maxSvgSize) {
this.lines = lines;
this.onSuccess = onSuccess;
this.onError = onError;
this.darkMode = darkMode;
this.maxSvgSize = maxSvgSize;
}
@Override
protected void attempt() {
svg = null;
failure = null;
try {
final SvgGraphicsJs graphics = buildSvg(lines, darkMode, maxSvgSize);
// null: discarded attempt, which is never delivered
if (graphics != null)
svg = serializeSvg(graphics.getSvgRoot());
} catch (Throwable t) {
failure = t;
}
}
/**
* Exactly one of the two callbacks is called, once. If the success callback
* throws, the error callback is not called as well.
*/
@Override
protected void deliver() {
if (svg != null)
PlantUMLBrowser.deliver(onSuccess, svg);
else
PlantUMLBrowser.deliver(onError, describe(failure));
}
@Override
protected void giveUp(String message) {
PlantUMLBrowser.deliver(onError, message);
}
}
// =========================================================================
// Exported entry points (called from JavaScript)
// =========================================================================
/**
* Single-string JS callback, used for both success (SVG) and error (message).
*/
@JSFunctor
public interface StringCallback extends JSObject {
void call(String value);
}
/**
* Renders a PlantUML diagram into the DOM element identified by
* {@code elementId}.
*
* <p>
* This call is asynchronous: it returns immediately, and the SVG is inserted
* later, once its turn has come and its resources are there (see the class
* documentation).
*
* <p>
* If a previous {@code render} request is still waiting, it is replaced by
* this one, and its diagram is never drawn. This is the desired behavior for
* live-typing scenarios. Waiting {@code renderToString} requests are not
* affected.
*
* @param lines the PlantUML source code, split into lines by the JavaScript
* caller
* @param elementId the {@code id} of the HTML element where the SVG should be
* rendered
* @param options optional JS object with rendering options (e.g. {@code {
* dark: true, maxSvgSize: 8192 }}); may be {@code null}.
* {@code maxSvgSize} overrides {@link #DEFAULT_MAX_SVG_SIZE};
* {@code 0} disables the size check.
*/
@JSExport
public static void render(String[] lines, String elementId, JSObject options) {
RenderQueue.submit(new ElementJob(lines, elementId, isDark(options), resolveMaxSvgSize(options)));
}
/**
* Renders a PlantUML diagram and delivers the resulting SVG as a string via the
* {@code onSuccess} callback. Errors go to {@code onError}.
*
* <p>
* Same queueing and asynchronous behavior as {@link #render}: a callback is
* never called before this call returns. Unlike {@link #render}, requests are never dropped:
* each call is queued behind the ones already waiting, and exactly one of
* {@code onSuccess} and {@code onError} is called for it, once, in the order of
* the calls. A callback that is {@code null} or that throws does not stop the
* engine (an exception is reported with {@code console.error}).
*
* @param lines the PlantUML source code, split into lines by the JavaScript
* caller
* @param onSuccess callback invoked with the SVG string when rendering succeeds
* @param onError callback invoked with an error message when rendering fails
* @param options optional JS object with rendering options (e.g. {@code {
* dark: true, maxSvgSize: 8192 }}); may be {@code null}.
* {@code maxSvgSize} overrides {@link #DEFAULT_MAX_SVG_SIZE};
* {@code 0} disables the size check.
*/
@JSExport
public static void renderToString(String[] lines, StringCallback onSuccess, StringCallback onError,
JSObject options) {
RenderQueue.submit(new CallbackJob(lines, onSuccess, onError, isDark(options), resolveMaxSvgSize(options)));
}
// =========================================================================
// Options extraction (called from JavaScript)
// =========================================================================
/**
* Extracts the {@code dark} boolean property from a JavaScript options object.
* Returns {@code false} if the object is null/undefined or if the property is
* absent.
*/
@JSBody(params = "opts", script = "return (opts && opts.dark === true);")
private static native boolean isDark(JSObject opts);
/**
* Extracts the {@code maxSvgSize} numeric property from a JavaScript options
* object. Returns {@code -1} as a sentinel when the object is null/undefined
* or the property is absent/not a number, meaning "use the default".
*/
@JSBody(params = "opts", script = "return (opts && typeof opts.maxSvgSize === 'number') ? opts.maxSvgSize : -1;")
private static native int extractMaxSvgSize(JSObject opts);
/**
* Resolves the effective max-SVG-size limit from the {@code options} object:
* the caller-supplied {@code maxSvgSize} when present, otherwise
* {@link #DEFAULT_MAX_SVG_SIZE}. A resolved value {@code <= 0} means
* "no limit".
*/
private static int resolveMaxSvgSize(JSObject opts) {
final int value = extractMaxSvgSize(opts);
return value < 0 ? DEFAULT_MAX_SVG_SIZE : value;
}
// =========================================================================
// Rendering
// =========================================================================
/**
* Parses and renders PlantUML source lines to an SVG graphics context, or
* returns <code>null</code> when the parsing missed a resource: the attempt
* is going to be discarded (see {@link RenderQueue}), so it stops before the
* layout and the drawing.
*/
private static SvgGraphicsJs buildSvg(String[] lines, boolean darkMode, int maxSvgSize) throws Exception {
final ColorMapper colorMapper = darkMode ? ColorMapper.TEAVM_DARK : ColorMapper.TEAVM_LIGHT;
final Diagram diagram = PSystemBuilderJs.getInstance().createDiagram(lines);
if (PendingResources.hasMissing())
return null;
final FileFormatOption fileFormat = new FileFormatOption(FileFormat.SVG);
if (diagram instanceof UgDiagram == false)
throw new RuntimeException("Unsupported diagram type");
final Scale scale = ((AbstractDiagram) diagram).getScale();
final UgDiagram ugDiagram = (UgDiagram) diagram;
TextBlock tb = ugDiagram.getTextBlock(0, fileFormat);
HColor tbBackcolor = tb.getBackcolor();
if (tbBackcolor == null && diagram instanceof TitledDiagram)
tbBackcolor = getPaintableDocumentBackground((TitledDiagram) diagram);
final SvgGraphicsJs svg;
if (tbBackcolor == null) {
svg = new SvgGraphicsJs();
tbBackcolor = darkMode ? HColors.BLACK : HColors.WHITE;
} else {
svg = new SvgGraphicsJs(tbBackcolor.toSvg(colorMapper));
}
UGraphic ug = UGraphicJs.build(tbBackcolor, colorMapper, STRING_BOUNDER, svg);
if (diagram instanceof TitledDiagram)
tb = DiagramChromeFactory.create(tb, (TitledDiagram) ugDiagram,
((TitledDiagram) ugDiagram).getSkinParam(), ugDiagram.getWarnings(), ((TitledDiagram) diagram).getTitle());
if (ugDiagram.isHandwritten())
ug = new UGraphicHandwritten(ug);
tb.drawU(ug);
final XDimension2D dim = tb.calculateDimension(STRING_BOUNDER);
if (maxSvgSize > 0 && (dim.getWidth() > maxSvgSize || dim.getHeight() > maxSvgSize))
throw new RuntimeException("Diagram too large for browser rendering: " + (int) dim.getWidth() + "x"
+ (int) dim.getHeight() + " (max " + maxSvgSize + "; override via the maxSvgSize option, or set it to 0 to disable this check)");
final double scaleFactor = scale == null ? 1.0 : scale.getScale(dim.getWidth(), dim.getHeight());
svg.updateSvgSize(dim.getWidth(), dim.getHeight(), scaleFactor);
// Embed the PlantUML source as a plantuml-src processing instruction, so the
// generated SVG can be re-imported/edited later, just like the SVGs produced
// by the classic Java backend and editor.plantuml.com.
// https://github.com/plantuml/plantuml/issues/2761
svg.addCommentMetadata(String.join("\n", lines));
return svg;
}
/**
* Returns the document background of the diagram when it should be painted,
* or null to keep the historic behaviour of painting nothing.
* <p>
* The document background is where both {@code skinparam backgroundColor} and
* a theme background land; it never surfaces on the TextBlock, so it is read
* from the merged root.document style, exactly like {@code ImageBuilder.styled()}
* does for the Java build.
* <p>
* The color is judged in its plain (light mapped) form so the decision is the
* same in both modes: transparent never paints, and the skin default of white
* must keep painting nothing, in dark mode too, where the skin pairs white
* with a dark value but the page has always supplied its own backdrop.
* Returning null also keeps the historic WHITE/BLACK fallback in
* {@code buildSvg} as the contrast reference for automatic colors.
*/
private static HColor getPaintableDocumentBackground(TitledDiagram diagram) {
final HColor documentBackground = diagram.calculateBackColor();
if (documentBackground == null)
return null;
final String plainColor = documentBackground.toSvg(ColorMapper.TEAVM_LIGHT);
if ("#00000000".equals(plainColor) || "#FFFFFF".equals(plainColor))
return null;
return documentBackground;
}
/**
* Calls a host callback. A missing callback (the host did not pass one) is
* ignored, and an exception thrown by the callback is contained, so that it
* cannot stop the engine. It is reported with {@code console.error}, as an
* error and not as debug output: it is a bug of the host that nothing else
* would show.
*/
private static void deliver(StringCallback callback, String value) {
if (callback == null)
return;
try {
callback.call(value);
} catch (Throwable t) {
RenderQueue.consoleError("PlantUML: callback threw: " + t);
}
}
/**
* Human-readable description of a rendering failure, never {@code null}.
*
* <p>
* {@code Throwable} (not only {@code Exception}) is caught by the callers so
* that an {@code Error} (e.g. {@code StackOverflowError}) reports a message
* instead of silently killing the worker thread. With {@code PLANTUML_DEBUG},
* the stack trace is also printed to the browser console.
*/
private static String describe(Throwable e) {
// Not possible in Java, but seen on the Wasm GC build: TeaVM 0.16.0
// miscompiled some try/finally blocks into a "throw null".
if (e == null)
return "Rendering error: null exception";
if (BrowserLog.isDebug())
e.printStackTrace();
// With an obfuscated build, toString() may return null when the class name is
// not kept and the exception has no message.
final String s = e.toString();
if (s != null)
return s;
final String message = e.getMessage();
return "Rendering error (" + (message == null ? "no message" : message) + ")";
}
// =========================================================================
// JavaScript interop utilities
// =========================================================================
/** Appends an SVG element as a child of a DOM element. */
@JSBody(params = { "p", "svg" }, script = "p.appendChild(svg);")
private static native void appendSvgElement(HTMLElement p, Element svg);
/** Removes all child nodes from a DOM element. */
@JSBody(params = "el", script = "while(el.firstChild)el.removeChild(el.firstChild);")
private static native void removeAllChildren(HTMLElement el);
/** Serializes an SVG DOM element to a string. */
@JSBody(params = "svg", script = "return new XMLSerializer().serializeToString(svg);")
private static native String serializeSvg(Element svg);
}