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);

}