SvgGraphicsTeaVM.java
/* ========================================================================
* PlantUML : a free UML diagram generator
* ========================================================================
*
* (C) Copyright 2009-2025, Arnaud Roques
*
* Project Info: https://plantuml.com
*
* If you like this project or if you find it useful, you can support us at:
*
* https://plantuml.com/patreon (only 1$ per month!)
* https://plantuml.com/paypal
*
* This file is part of PlantUML.
*
* PlantUML is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PlantUML distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
* USA.
*
*
* Original Author: Arnaud Roques
*
*/
package net.sourceforge.plantuml.teavm;
import org.teavm.jso.JSBody;
import org.teavm.jso.dom.html.HTMLDocument;
import org.teavm.jso.dom.xml.Document;
import org.teavm.jso.dom.xml.Element;
/**
* SVG Graphics implementation for TeaVM. Uses browser's native DOM API instead
* of javax.xml.
*/
public class SvgGraphicsTeaVM {
// ::remove file when JAVA8
private static final String SVG_NS = "http://www.w3.org/2000/svg";
private final Element svgRoot;
private final Element defs;
private final Element mainGroup;
private final Document document;
private String fillColor = "#000000";
private String strokeColor = "#000000";
private double strokeWidth = 1.0;
private double[] strokeDasharray = null;
public SvgGraphicsTeaVM(int width, int height) {
this.document = HTMLDocument.current();
// Create SVG root element
this.svgRoot = createSvgElement("svg");
svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("width", String.valueOf(width));
svgRoot.setAttribute("height", String.valueOf(height));
svgRoot.setAttribute("viewBox", "0 0 " + width + " " + height);
// Create defs for gradients, filters, etc.
this.defs = createSvgElement("defs");
svgRoot.appendChild(defs);
// Create main group for all drawings
this.mainGroup = createSvgElement("g");
svgRoot.appendChild(mainGroup);
}
@JSBody(params = { "tagName" }, script = "return document.createElementNS('http://www.w3.org/2000/svg', tagName);")
private static native Element createSvgElement(String tagName);
public Element getSvgRoot() {
return svgRoot;
}
public void setFillColor(String color) {
this.fillColor = color != null ? color : "none";
}
public void setStrokeColor(String color) {
this.strokeColor = color != null ? color : "none";
}
public void setStrokeWidth(double width) {
this.strokeWidth = width;
this.strokeDasharray = null;
}
public void setStrokeWidth(double width, double[] dasharray) {
this.strokeWidth = width;
this.strokeDasharray = dasharray;
}
public void drawRectangle(double x, double y, double width, double height) {
drawRectangle(x, y, width, height, 0, 0);
}
public void drawRectangle(double x, double y, double width, double height, double rx, double ry) {
Element rect = createSvgElement("rect");
rect.setAttribute("x", format(x));
rect.setAttribute("y", format(y));
rect.setAttribute("width", format(width));
rect.setAttribute("height", format(height));
if (rx > 0)
rect.setAttribute("rx", format(rx));
if (ry > 0)
rect.setAttribute("ry", format(ry));
applyStyles(rect);
mainGroup.appendChild(rect);
}
public void drawCircle(double cx, double cy, double r) {
Element circle = createSvgElement("circle");
circle.setAttribute("cx", format(cx));
circle.setAttribute("cy", format(cy));
circle.setAttribute("r", format(r));
applyStyles(circle);
mainGroup.appendChild(circle);
}
public void drawEllipse(double cx, double cy, double rx, double ry) {
Element ellipse = createSvgElement("ellipse");
ellipse.setAttribute("cx", format(cx));
ellipse.setAttribute("cy", format(cy));
ellipse.setAttribute("rx", format(rx));
ellipse.setAttribute("ry", format(ry));
applyStyles(ellipse);
mainGroup.appendChild(ellipse);
}
public void drawLine(double x1, double y1, double x2, double y2) {
Element line = createSvgElement("line");
line.setAttribute("x1", format(x1));
line.setAttribute("y1", format(y1));
line.setAttribute("x2", format(x2));
line.setAttribute("y2", format(y2));
applyStrokeStyle(line);
mainGroup.appendChild(line);
}
public void drawPolyline(double... points) {
Element polyline = createSvgElement("polyline");
polyline.setAttribute("points", formatPoints(points));
polyline.setAttribute("fill", "none");
applyStrokeStyle(polyline);
mainGroup.appendChild(polyline);
}
public void drawPolygon(double... points) {
Element polygon = createSvgElement("polygon");
polygon.setAttribute("points", formatPoints(points));
applyStyles(polygon);
mainGroup.appendChild(polygon);
}
public void drawPath(String pathData) {
Element path = createSvgElement("path");
path.setAttribute("d", pathData);
applyStyles(path);
mainGroup.appendChild(path);
}
public void drawText(String text, double x, double y, String fontFamily, int fontSize) {
drawText(text, x, y, fontFamily, fontSize, null, null, null, null);
}
public void drawText(String text, double x, double y, String fontFamily, int fontSize, String fontWeight,
String fontStyle, String textDecoration, String backColor) {
// Draw background rectangle if backColor is specified
if (backColor != null) {
double[] metrics = measureTextCanvas(text, fontFamily, fontSize,
fontWeight != null ? fontWeight : "normal");
double width = metrics[0];
double height = metrics[1];
Element rect = createSvgElement("rect");
rect.setAttribute("x", format(x));
rect.setAttribute("y", format(y - height + 2));
rect.setAttribute("width", format(width));
rect.setAttribute("height", format(height));
rect.setAttribute("fill", backColor);
rect.setAttribute("stroke", "none");
mainGroup.appendChild(rect);
}
Element textElem = createSvgElement("text");
textElem.setAttribute("x", format(x));
textElem.setAttribute("y", format(y));
textElem.setAttribute("font-size", String.valueOf(fontSize));
textElem.setAttribute("fill", fillColor);
// Preserve whitespace (multiple spaces, tabs, etc.)
textElem.setAttribute("xml:space", "preserve");
textElem.setAttribute("style", "white-space: pre");
if (fontWeight != null) {
textElem.setAttribute("font-weight", fontWeight);
}
if (fontStyle != null) {
textElem.setAttribute("font-style", fontStyle);
}
if (textDecoration != null) {
textElem.setAttribute("text-decoration", textDecoration);
}
if (fontFamily != null) {
textElem.setAttribute("font-family", fontFamily);
// For monospace fonts, replace spaces with non-breaking spaces
// to ensure consistent character width
// if (fontFamily.equalsIgnoreCase("monospace") ||
// fontFamily.equalsIgnoreCase("courier"))
// text = text.replace(' ', (char) 160);
}
textElem.setTextContent(text);
mainGroup.appendChild(textElem);
}
public Element createGroup() {
Element group = createSvgElement("g");
mainGroup.appendChild(group);
return group;
}
public String createLinearGradient(String id, String color1, String color2, boolean horizontal) {
Element gradient = createSvgElement("linearGradient");
gradient.setAttribute("id", id);
if (horizontal) {
gradient.setAttribute("x1", "0%");
gradient.setAttribute("y1", "0%");
gradient.setAttribute("x2", "100%");
gradient.setAttribute("y2", "0%");
} else {
gradient.setAttribute("x1", "0%");
gradient.setAttribute("y1", "0%");
gradient.setAttribute("x2", "0%");
gradient.setAttribute("y2", "100%");
}
Element stop1 = createSvgElement("stop");
stop1.setAttribute("offset", "0%");
stop1.setAttribute("stop-color", color1);
gradient.appendChild(stop1);
Element stop2 = createSvgElement("stop");
stop2.setAttribute("offset", "100%");
stop2.setAttribute("stop-color", color2);
gradient.appendChild(stop2);
defs.appendChild(gradient);
return "url(#" + id + ")";
}
private void applyStyles(Element element) {
element.setAttribute("fill", fillColor);
applyStrokeStyle(element);
}
private void applyStrokeStyle(Element element) {
element.setAttribute("stroke", strokeColor);
element.setAttribute("stroke-width", format(strokeWidth));
if (strokeDasharray != null && strokeDasharray.length >= 2)
element.setAttribute("stroke-dasharray", format(strokeDasharray[0]) + "," + format(strokeDasharray[1]));
}
private String format(double value) {
if (value == (int) value)
return String.valueOf((int) value);
return String.format("%.2f", value).replace(',', '.');
}
private String formatPoints(double... points) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < points.length; i += 2) {
if (sb.length() > 0)
sb.append(" ");
sb.append(format(points[i])).append(",").append(format(points[i + 1]));
}
return sb.toString();
}
/**
* Returns the SVG as a string (serialized XML).
*/
@JSBody(params = { "element" }, script = "return new XMLSerializer().serializeToString(element);")
public static native String serializeToString(Element element);
public String toSvgString() {
return serializeToString(svgRoot);
}
// ========================================================================
// Text measurement methods
// ========================================================================
/**
* Initializes the shared canvas for text measurement. Call once at startup for
* best performance.
*/
@JSBody(script = "if (!window._measureCanvas) {" + " window._measureCanvas = document.createElement('canvas');"
+ " window._measureCtx = window._measureCanvas.getContext('2d');" + "}")
public static native void initMeasureCanvas();
/**
* Measures text dimensions using Canvas API (optimized with shared canvas).
*
* @param text The text to measure
* @param fontFamily Font family (e.g., "Arial")
* @param fontSize Font size in pixels
* @param fontWeight Font weight (e.g., "normal", "bold")
* @return Array with [width, height]
*/
@JSBody(params = { "text", "fontFamily", "fontSize", "fontWeight" }, script = "if (!window._measureCtx) {"
+ " window._measureCanvas = document.createElement('canvas');"
+ " window._measureCtx = window._measureCanvas.getContext('2d');" + "}" + "var ctx = window._measureCtx;"
+ "ctx.font = fontWeight + ' ' + fontSize + 'px ' + fontFamily;" + "var metrics = ctx.measureText(text);"
+ "var width = metrics.width;"
+ "var height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;"
+ "if (!height) height = fontSize * 1.2;" + "return [width, height];")
public static native double[] measureTextCanvas(String text, String fontFamily, int fontSize, String fontWeight);
/**
* Measures text dimensions using Canvas API (creates new canvas each time). Use
* measureTextCanvas() instead for better performance.
*/
@JSBody(params = { "text", "fontFamily", "fontSize",
"fontWeight" }, script = "var canvas = document.createElement('canvas');"
+ "var ctx = canvas.getContext('2d');"
+ "ctx.font = fontWeight + ' ' + fontSize + 'px ' + fontFamily;"
+ "var metrics = ctx.measureText(text);" + "var width = metrics.width;"
+ "var height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;"
+ "if (!height) height = fontSize * 1.2;" + "return [width, height];")
public static native double[] measureTextCanvasNoCache(String text, String fontFamily, int fontSize,
String fontWeight);
/**
* Measures text using Canvas with normal weight.
*/
public static double[] measureText(String text, String fontFamily, int fontSize) {
return measureTextCanvas(text, fontFamily, fontSize, "normal");
}
/**
* Gets text width only.
*/
public static double getTextWidth(String text, String fontFamily, int fontSize) {
return measureText(text, fontFamily, fontSize)[0];
}
/**
* Gets text height only.
*/
public static double getTextHeight(String text, String fontFamily, int fontSize) {
return measureText(text, fontFamily, fontSize)[1];
}
/**
* Measures text using SVG getBBox() method. More accurate but requires the SVG
* to be in the DOM.
*
* @param text The text to measure
* @param fontFamily Font family
* @param fontSize Font size in pixels
* @return Array with [width, height, x, y] from bounding box
*/
@JSBody(params = { "text", "fontFamily",
"fontSize" }, script = "var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');"
+ "svg.style.position = 'absolute';" + "svg.style.visibility = 'hidden';"
+ "document.body.appendChild(svg);"
+ "var textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');"
+ "textEl.setAttribute('font-family', fontFamily);" + "textEl.setAttribute('font-size', fontSize);"
+ "textEl.textContent = text;" + "svg.appendChild(textEl);" + "var bbox = textEl.getBBox();"
+ "var result = [bbox.width, bbox.height, bbox.x, bbox.y];" + "document.body.removeChild(svg);"
+ "return result;")
public static native double[] measureTextSvgBBox(String text, String fontFamily, int fontSize);
/**
* Detailed text metrics using Canvas API. Returns more information about text
* positioning.
*
* @return Array with [width, actualBoundingBoxAscent, actualBoundingBoxDescent,
* fontBoundingBoxAscent, fontBoundingBoxDescent]
*/
@JSBody(params = { "text", "fontFamily", "fontSize",
"fontWeight" }, script = "var canvas = document.createElement('canvas');"
+ "var ctx = canvas.getContext('2d');"
+ "ctx.font = fontWeight + ' ' + fontSize + 'px ' + fontFamily;" + "var m = ctx.measureText(text);"
+ "return [" + " m.width," + " m.actualBoundingBoxAscent || fontSize * 0.8,"
+ " m.actualBoundingBoxDescent || fontSize * 0.2," + " m.fontBoundingBoxAscent || fontSize * 0.8,"
+ " m.fontBoundingBoxDescent || fontSize * 0.2" + "];")
public static native double[] getDetailedTextMetrics(String text, String fontFamily, int fontSize,
String fontWeight);
// ========================================================================
// Centered character drawing
// ========================================================================
/**
* Draws a single character centered at the specified position. Uses SVG
* text-anchor and dominant-baseline for centering.
*
* @param c The character to draw
* @param x Center X position
* @param y Center Y position
* @param fontFamily Font family
* @param fontSize Font size in pixels
*/
public void drawCenteredCharacter(char c, double x, double y, String fontFamily, int fontSize) {
Element textElem = createSvgElement("text");
textElem.setAttribute("x", format(x));
textElem.setAttribute("y", format(y));
textElem.setAttribute("font-family", fontFamily);
textElem.setAttribute("font-size", String.valueOf(fontSize));
textElem.setAttribute("fill", fillColor);
// Center horizontally
textElem.setAttribute("text-anchor", "middle");
// Center vertically (dominant-baseline: central centers on x-height)
textElem.setAttribute("dominant-baseline", "central");
textElem.setTextContent(String.valueOf(c));
mainGroup.appendChild(textElem);
}
// ========================================================================
// Image drawing
// ========================================================================
/**
* Draws an image at the specified position using a data URL. The image is
* embedded directly in the SVG as a base64-encoded PNG.
*
* @param dataUrl PNG data URL (data:image/png;base64,...)
* @param x X position
* @param y Y position
* @param width Image width
* @param height Image height
*/
public void drawImage(String dataUrl, double x, double y, int width, int height) {
Element image = createSvgElement("image");
image.setAttribute("x", format(x));
image.setAttribute("y", format(y));
image.setAttribute("width", String.valueOf(width));
image.setAttribute("height", String.valueOf(height));
// Use href for SVG 2.0 compatibility (xlink:href is deprecated)
image.setAttribute("href", dataUrl);
// Also set xlink:href for older browser compatibility
setXlinkHref(image, dataUrl);
mainGroup.appendChild(image);
}
@JSBody(params = { "element",
"href" }, script = "element.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', href);")
private static native void setXlinkHref(Element element, String href);
// ========================================================================
// SVG image embedding
// ========================================================================
/**
* Embeds an SVG image at the specified position. The SVG content is parsed and
* inserted as a nested SVG element.
*
* @param image UImageSvg containing the SVG data
* @param x X position
* @param y Y position
*/
public void drawSvgImage(net.sourceforge.plantuml.klimt.shape.UImageSvg image, double x, double y) {
final double svgScale = image.getScale();
String svg = image.getSvg(false);
// Handle scaling if needed
if (svgScale != 1) {
svg = wrapWithScaleTransform(svg, svgScale);
}
// Create a group to position the embedded SVG
Element wrapper = createSvgElement("g");
wrapper.setAttribute("transform", "translate(" + format(x) + "," + format(y) + ")");
// Parse and insert the SVG content
insertSvgContent(wrapper, svg);
mainGroup.appendChild(wrapper);
}
/**
* Wraps SVG content with a scale transform.
*/
private String wrapWithScaleTransform(String svg, double scale) {
String svg2 = svg.replace('\n', ' ').replace('\r', ' ');
if (!svg2.contains("<g ") && !svg2.contains("<g>")) {
svg = svg.replaceFirst("\\<svg\\>", "<svg><g>");
svg = svg.replaceFirst("\\</svg\\>", "</g></svg>");
}
final String factor = format(scale);
svg = svg.replaceFirst("\\<g\\b", "<g transform=\"scale(" + factor + "," + factor + ")\" ");
return svg;
}
/**
* Parses SVG string and inserts its content into the parent element.
*/
@JSBody(params = { "parent", "svgContent" }, script = "var parser = new DOMParser();"
+ "var doc = parser.parseFromString(svgContent, 'image/svg+xml');" + "var svgElem = doc.documentElement;"
+ "if (svgElem && svgElem.tagName.toLowerCase() === 'svg') {"
+ " var imported = document.importNode(svgElem, true);" + " parent.appendChild(imported);" + "}")
private static native void insertSvgContent(Element parent, String svgContent);
}