Surface.java
package io.brunoborges.jairosvg.surface;
import static io.brunoborges.jairosvg.util.Helpers.getViewbox;
import static io.brunoborges.jairosvg.util.Helpers.nodeFormat;
import static io.brunoborges.jairosvg.util.Helpers.normalize;
import static io.brunoborges.jairosvg.util.Helpers.parseDoubleOr;
import static io.brunoborges.jairosvg.util.Helpers.preserveRatio;
import static io.brunoborges.jairosvg.util.Helpers.size;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.geom.AffineTransform;
import java.awt.geom.GeneralPath;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.awt.font.FontRenderContext;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
import io.brunoborges.jairosvg.css.Colors;
import io.brunoborges.jairosvg.dom.BoundingBox;
import io.brunoborges.jairosvg.dom.Node;
import io.brunoborges.jairosvg.dom.SvgFont;
import io.brunoborges.jairosvg.draw.Defs;
import io.brunoborges.jairosvg.draw.FilterRenderer;
import io.brunoborges.jairosvg.draw.ImageHandler;
import io.brunoborges.jairosvg.draw.MarkerDrawer;
import io.brunoborges.jairosvg.draw.MaskPainter;
import io.brunoborges.jairosvg.draw.PathDrawer;
import io.brunoborges.jairosvg.draw.ShapeDrawer;
import io.brunoborges.jairosvg.draw.SvgDrawer;
import io.brunoborges.jairosvg.draw.TextDrawer;
import io.brunoborges.jairosvg.util.Helpers;
import io.brunoborges.jairosvg.util.UrlHelper;
/**
* Abstract base surface for SVG rendering using Java2D.
*/
public sealed class Surface permits PngSurface, JpegSurface, TiffSurface, PdfSurface, PsSurface {
private static final Set<String> INVISIBLE_TAGS = Set.of("clipPath", "cursor", "desc", "filter", "font",
"font-face", "foreignObject", "glyph", "linearGradient", "marker", "mask", "metadata", "missing-glyph",
"pattern", "radialGradient", "symbol", "title");
private static final java.util.regex.Pattern WHITESPACE = java.util.regex.Pattern.compile("\\s+");
private static final float[] NO_DASH = new float[0];
// Rendering state
public Graphics2D context;
public GeneralPath path = new GeneralPath();
public double contextWidth;
public double contextHeight;
public double[] cursorPosition = {0, 0};
public double dpi;
public double fontSize;
public boolean strokeAndFill = true;
public Node parentNode;
public Node rootNode;
// Definition stores
public Map<String, Node> markers = new LinkedHashMap<>();
public Map<String, Node> gradients = new LinkedHashMap<>();
public Map<String, Node> patterns = new LinkedHashMap<>();
public Map<String, Node> masks = new LinkedHashMap<>();
public Map<String, Node> paths = new LinkedHashMap<>();
public Map<String, Node> filters = new LinkedHashMap<>();
public Map<String, Node> images = new LinkedHashMap<>();
public Map<String, SvgFont> fonts = new LinkedHashMap<>();
// Reusable mask buffer (lazily allocated, avoids per-mask allocation)
public BufferedImage maskBuffer;
// Reusable filter primitive buffers (lazily allocated, shared across filter
// invocations)
public BufferedImage filterBuf1, filterBuf2, filterBuf3;
// Reusable off-screen effect buffer for filters/masks/opacity
private BufferedImage effectBuffer;
private boolean effectBufferInUse;
// Pattern transform (set by PatternPainter, consumed by fill/stroke)
public AffineTransform paintTransform;
// Surface dimensions
protected BufferedImage image;
protected double width;
protected double height;
protected OutputStream output;
// Color mapping
private UnaryOperator<Colors.RGBA> mapRgba;
// Dash array cache (keyed by raw stroke-dasharray string)
private final Map<String, float[]> dashArrayCache = new HashMap<>();
// Solid color cache: maps color string → AWT Color for opacity=1.0 (avoids
// re-parsing hex/named colors and re-creating Color objects on every render)
private final Map<String, Color> solidColorCache = new HashMap<>();
// Raster image cache (keyed by data: URI or resolved URL)
public Map<String, BufferedImage> rasterImageCache = new HashMap<>();
// Pre-allocated transform stack to avoid AffineTransform allocations per node
private final AffineTransform[] transformStack = buildTransformStack();
private int transformDepth = 0;
private static final AffineTransform IDENTITY_TRANSFORM = new AffineTransform();
private static AffineTransform[] buildTransformStack() {
AffineTransform[] s = new AffineTransform[64];
for (int i = 0; i < s.length; i++)
s[i] = new AffineTransform();
return s;
}
// Cached FontRenderContext — valid as long as rendering hints don't change
public FontRenderContext cachedFRC;
// Gradient stop cache to avoid re-parsing stops on every gradient application
public record GradientStops(float[] fractions, Color[] colors, double opacity) {
}
public final IdentityHashMap<Node, GradientStops> gradientStopCache = new IdentityHashMap<>();
// ID index for O(1) element lookup (used by <use> and others)
public final Map<String, Node> nodeById = new HashMap<>();
public Surface() {
}
/** Initialize the surface with optional rendering hint overrides. */
public void init(Node tree, OutputStream output, double dpi, Double parentWidth, Double parentHeight, double scale,
Double outputWidth, Double outputHeight, String backgroundColor, UnaryOperator<Colors.RGBA> mapRgba,
Map<RenderingHints.Key, Object> renderingHintOverrides) {
this.output = output;
this.dpi = dpi;
this.mapRgba = mapRgba;
this.contextWidth = parentWidth != null ? parentWidth : 0;
this.contextHeight = parentHeight != null ? parentHeight : 0;
this.rootNode = tree;
this.fontSize = size(this, "12pt");
double[] nf = nodeFormat(this, tree);
double w = nf[0], h = nf[1];
double[] viewbox = getViewbox(nf);
if (viewbox == null) {
viewbox = new double[]{0, 0, w, h};
}
if (outputWidth != null && outputHeight != null) {
w = outputWidth;
h = outputHeight;
} else if (outputWidth != null) {
if (w > 0)
h *= outputWidth / w;
w = outputWidth;
} else if (outputHeight != null) {
if (h > 0)
w *= outputHeight / h;
h = outputHeight;
} else {
w *= scale;
h *= scale;
}
createSurface(w, h);
this.context = image.createGraphics();
setupRenderingHints(renderingHintOverrides);
setContextSize(w, h, viewbox, tree);
context.translate(0, 0);
if (backgroundColor != null) {
Colors.RGBA bg = Colors.color(backgroundColor);
context.setColor(new Color((float) bg.r(), (float) bg.g(), (float) bg.b(), (float) bg.a()));
context.fillRect(0, 0, image.getWidth(), image.getHeight());
}
draw(tree);
}
protected void createSurface(double w, double h) {
int iw = Math.max(1, (int) Math.round(w));
int ih = Math.max(1, (int) Math.round(h));
this.image = new BufferedImage(iw, ih, BufferedImage.TYPE_INT_ARGB);
this.width = iw;
this.height = ih;
}
private void setupRenderingHints(Map<RenderingHints.Key, Object> overrides) {
context.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
context.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
if (overrides != null) {
overrides.forEach(context::setRenderingHint);
}
this.cachedFRC = context.getFontRenderContext();
}
/** Set the context size and apply viewport transformations. */
public void setContextSize(double w, double h, double[] viewbox, Node tree) {
double rectX, rectY;
if (viewbox != null) {
rectX = viewbox[0];
rectY = viewbox[1];
tree.imageWidth = viewbox[2];
tree.imageHeight = viewbox[3];
} else {
rectX = 0;
rectY = 0;
tree.imageWidth = w;
tree.imageHeight = h;
}
double[] ratio = preserveRatio(this, tree, w, h);
double scaleX = ratio[0], scaleY = ratio[1];
double translateX = ratio[2], translateY = ratio[3];
// Clip to viewport
if (!"visible".equals(tree.get("overflow", "hidden"))) {
context.setClip(new Rectangle2D.Double(0, 0, w, h));
}
context.scale(scaleX, scaleY);
context.translate(translateX - rectX, translateY - rectY);
this.contextWidth = w / scaleX;
this.contextHeight = h / scaleY;
}
/** Map a color string through the color mapper. */
public Colors.RGBA mapColor(String string, double opacity) {
Colors.RGBA rgba = Colors.color(string, opacity);
return mapRgba != null ? mapRgba.apply(rgba) : rgba;
}
/** Main draw method - renders a node and its children. */
public void draw(Node node) {
if ("svg".equals(node.tag)) {
Defs.parseAllDefs(this, node);
}
if ("defs".equals(node.tag))
return;
if ((node.has("width") && size(this, node.get("width")) == 0)
|| (node.has("height") && size(this, node.get("height")) == 0)) {
return;
}
// Save state
Node oldParentNode = this.parentNode;
double oldFontSize = this.fontSize;
double oldContextWidth = this.contextWidth;
double oldContextHeight = this.contextHeight;
this.parentNode = node;
this.fontSize = size(this, node.get("font-size", "12pt"));
int savedDepth = transformDepth;
transformStack[transformDepth++].setTransform(context.getTransform());
Shape savedClip = context.getClip();
Composite savedComposite = context.getComposite();
Stroke savedStroke = context.getStroke();
// Apply transformations
Helpers.transform(this, node, node.get("transform"), node.get("transform-origin"));
// Filter and opacity
String filterName = null;
String filterStr = node.get("filter");
if (filterStr != null) {
filterName = UrlHelper.parseUrl(filterStr).fragment();
}
String maskName = null;
String maskStr = node.get("mask");
if (maskStr != null) {
maskName = UrlHelper.parseUrl(maskStr).fragment();
}
double opacity = parseDoubleOr(node.get("opacity"), 1);
boolean groupOpacity = opacity < 1 && !node.children.isEmpty();
if (filterName != null) {
FilterRenderer.prepareFilter(this, node, filterName);
}
Graphics2D effectBaseContext = null;
Graphics2D effectContext = null;
BufferedImage effectSourceImage = null;
boolean subRegionEffect = false;
int ebX = 0, ebY = 0;
AffineTransform subRegionXform = null;
if (filterName != null || maskName != null || groupOpacity) {
effectBaseContext = context;
int fullW = image.getWidth();
int fullH = image.getHeight();
int iw = fullW, ih = fullH;
// Sub-region effect buffers for mask/opacity-only elements (no filter).
// Filters are excluded because primitives like feFlood fill the entire
// buffer, making the result dependent on buffer dimensions. Filter
// sub-region optimization is handled inside applyFilter() instead.
if (filterName == null) {
BoundingBox.Box bbox = BoundingBox.calculate(this, node);
if (bbox != null && BoundingBox.isNonEmpty(bbox)) {
AffineTransform xf = context.getTransform();
double[] pts = {bbox.minX(), bbox.minY(), bbox.minX() + bbox.width(), bbox.minY(), bbox.minX(),
bbox.minY() + bbox.height(), bbox.minX() + bbox.width(), bbox.minY() + bbox.height()};
double[] dst = new double[8];
xf.transform(pts, 0, dst, 0, 4);
double dMinX = Math.min(Math.min(dst[0], dst[2]), Math.min(dst[4], dst[6]));
double dMinY = Math.min(Math.min(dst[1], dst[3]), Math.min(dst[5], dst[7]));
double dMaxX = Math.max(Math.max(dst[0], dst[2]), Math.max(dst[4], dst[6]));
double dMaxY = Math.max(Math.max(dst[1], dst[3]), Math.max(dst[5], dst[7]));
int pad = computeEffectPadding(node);
ebX = Math.max(0, (int) Math.floor(dMinX) - pad);
ebY = Math.max(0, (int) Math.floor(dMinY) - pad);
int ebW = Math.min(fullW, (int) Math.ceil(dMaxX) + pad) - ebX;
int ebH = Math.min(fullH, (int) Math.ceil(dMaxY) + pad) - ebY;
if (ebW > 0 && ebH > 0 && (long) ebW * ebH < (long) fullW * fullH * 4 / 5) {
iw = ebW;
ih = ebH;
subRegionEffect = true;
}
}
}
if (!subRegionEffect && !effectBufferInUse && effectBuffer != null && effectBuffer.getWidth() == iw
&& effectBuffer.getHeight() == ih) {
effectSourceImage = effectBuffer;
java.util.Arrays.fill(
((java.awt.image.DataBufferInt) effectSourceImage.getRaster().getDataBuffer()).getData(), 0);
} else {
effectSourceImage = new BufferedImage(iw, ih, BufferedImage.TYPE_INT_ARGB);
if (!effectBufferInUse && !subRegionEffect) {
effectBuffer = effectSourceImage;
}
}
effectBufferInUse = true;
effectContext = effectSourceImage.createGraphics();
effectContext.setRenderingHints(effectBaseContext.getRenderingHints());
if (subRegionEffect) {
subRegionXform = new AffineTransform(effectBaseContext.getTransform());
subRegionXform.preConcatenate(AffineTransform.getTranslateInstance(-ebX, -ebY));
effectContext.setTransform(subRegionXform);
} else {
effectContext.setTransform(effectBaseContext.getTransform());
}
effectContext.setClip(effectBaseContext.getClip());
effectContext.setComposite(effectBaseContext.getComposite());
effectContext.setStroke(effectBaseContext.getStroke());
context = effectContext;
}
// Set stroke properties
configureStroke(node);
// Clip
applyClip(node);
// Reset path for this node
path.reset();
// Draw the tag
String tag = node.tag;
boolean drawn = true;
try {
switch (tag) {
case "a", "text", "textPath", "tspan" -> TextDrawer.text(this, node);
case "circle" -> ShapeDrawer.circle(this, node);
case "clipPath" -> Defs.clipPath(this, node);
case "ellipse" -> ShapeDrawer.ellipse(this, node);
case "filter" -> Defs.filter(this, node);
case "image" -> ImageHandler.image(this, node);
case "line" -> ShapeDrawer.line(this, node);
case "linearGradient" -> Defs.linearGradient(this, node);
case "marker" -> Defs.marker(this, node);
case "mask" -> Defs.mask(this, node);
case "path" -> PathDrawer.path(this, node);
case "pattern" -> Defs.pattern(this, node);
case "polyline" -> ShapeDrawer.polyline(this, node);
case "polygon" -> ShapeDrawer.polygon(this, node);
case "radialGradient" -> Defs.radialGradient(this, node);
case "rect" -> ShapeDrawer.rect(this, node);
case "svg" -> SvgDrawer.svg(this, node);
case "use" -> Defs.use(this, node);
default -> drawn = false;
}
} catch (Helpers.PointError e) {
// Ignore point parsing errors
}
// Manage display
boolean display = !"none".equals(node.get("display", "inline"));
// Fill and stroke — defer opacity/visibility computation until needed
if (strokeAndFill && drawn && !path.getPathIterator(null).isDone()) {
boolean visible = display && !"hidden".equals(node.get("visibility", "visible"));
if (visible) {
double strokeOpacity = parseDoubleOr(node.get("stroke-opacity"), 1);
double fillOpacity = parseDoubleOr(node.get("fill-opacity"), 1);
if (opacity < 1 && node.children.isEmpty()) {
strokeOpacity *= opacity;
fillOpacity *= opacity;
}
// Fill — skip entirely when fill is "none" or fully transparent
String fillStr = node.get("fill", "black");
boolean doFill = !"none".equals(fillStr);
if (doFill) {
boolean gradientFill = false;
String fillColorStr = fillStr;
if (fillStr.charAt(0) == 'u' && fillStr.startsWith("url")) {
String[] paintValue = Helpers.paint(fillStr);
gradientFill = Defs.gradientOrPattern(this, node, paintValue[0], fillOpacity);
if (!gradientFill) {
fillColorStr = paintValue[1];
}
}
if (!gradientFill) {
if (fillOpacity >= 1.0 && mapRgba == null) {
Color cached = solidColorCache.get(fillColorStr);
if (cached != null) {
context.setColor(cached);
} else {
Colors.RGBA fillColor = Colors.color(fillColorStr, 1.0);
doFill = fillColor.a() > 0;
if (doFill) {
cached = toAwtColor(fillColor);
solidColorCache.put(fillColorStr, cached);
context.setColor(cached);
}
}
} else {
Colors.RGBA fillColor = mapColor(fillColorStr, fillOpacity);
doFill = fillColor.a() > 0;
if (doFill)
context.setColor(toAwtColor(fillColor));
}
}
if (doFill || gradientFill) {
// Set fill rule
if ("evenodd".equals(node.get("fill-rule"))) {
path.setWindingRule(GeneralPath.WIND_EVEN_ODD);
} else {
path.setWindingRule(GeneralPath.WIND_NON_ZERO);
}
paintWithTransform(path, true);
}
}
// Stroke
String[] strokePaint = Helpers.paint(node.get("stroke"));
if (strokePaint[1] != null && !"none".equals(strokePaint[1])) {
if (!Defs.gradientOrPattern(this, node, strokePaint[0], strokeOpacity)) {
Colors.RGBA strokeColor = mapColor(strokePaint[1], strokeOpacity);
if (strokeColor.a() > 0) {
context.setColor(toAwtColor(strokeColor));
} else {
strokePaint[1] = null; // suppress stroke
}
}
if (strokePaint[1] != null) {
float strokeWidth = (float) size(this, node.get("stroke-width", "1"));
int cap = getLineCap(node.get("stroke-linecap"));
int join = getLineJoin(node.get("stroke-linejoin"));
float miterLimit = (float) parseDoubleOr(node.get("stroke-miterlimit"), 4);
// Dash array
String dashStr = node.get("stroke-dasharray", "").strip();
float[] dashArray = null;
if (!dashStr.isEmpty() && !"none".equals(dashStr)) {
float[] cached = dashArrayCache.computeIfAbsent(dashStr, k -> {
String[] parts = WHITESPACE.split(normalize(k));
float[] arr = new float[parts.length];
float sum = 0;
for (int i = 0; i < parts.length; i++) {
arr[i] = (float) size(Surface.this, parts[i]);
sum += arr[i];
}
return sum == 0 ? NO_DASH : arr;
});
if (cached != NO_DASH) {
dashArray = cached;
}
}
float dashOffset = (float) size(this, node.get("stroke-dashoffset"));
BasicStroke stroke = dashArray != null
? new BasicStroke(strokeWidth, cap, join, miterLimit, dashArray, dashOffset)
: new BasicStroke(strokeWidth, cap, join, miterLimit);
context.setStroke(stroke);
paintWithTransform(path, false);
}
}
MarkerDrawer.drawMarkers(this, node);
} else {
path.reset();
}
}
// Draw children
if (display && !INVISIBLE_TAGS.contains(node.tag)) {
for (Node child : node.children) {
draw(child);
}
}
// Restore state
transformDepth = savedDepth;
context.setTransform(transformStack[savedDepth]);
context.setClip(savedClip);
context.setComposite(savedComposite);
context.setStroke(savedStroke);
if (effectContext != null) {
// Save pre-node transform in a local variable before mask/filter
// processing can clobber transformStack[savedDepth] via recursive
// draw() calls (mask children, feImage, etc.).
AffineTransform preNodeTransform = new AffineTransform(transformStack[savedDepth]);
BufferedImage renderedImage = effectSourceImage;
java.awt.Rectangle filterClip = null;
if (filterName != null) {
// Compute the filter region BEFORE filtering so that primitives like
// feFlood don't bleed into other elements' areas when composited back.
Node filterNode = this.filters.get(filterName);
filterClip = FilterRenderer.computeFilterRegion(effectSourceImage, filterNode);
renderedImage = FilterRenderer.applyFilter(this, filterName, renderedImage, filterClip);
}
if (maskName != null) {
renderedImage = MaskPainter.paintMask(this, node, maskName, renderedImage,
subRegionEffect ? subRegionXform : null);
}
context = effectBaseContext;
if (groupOpacity) {
effectBaseContext.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, (float) opacity));
}
// Full-size effect buffers already contain content at device
// coordinates (the node's transform was applied during rendering).
// Composite with identity transform to avoid double-applying it.
effectBaseContext.setTransform(IDENTITY_TRANSFORM);
if (subRegionEffect) {
effectBaseContext.drawImage(renderedImage, ebX, ebY, null);
} else {
if (filterClip != null) {
Shape prevClip = effectBaseContext.getClip();
effectBaseContext.clip(filterClip);
effectBaseContext.drawImage(renderedImage, 0, 0, null);
effectBaseContext.setClip(prevClip);
} else {
effectBaseContext.drawImage(renderedImage, 0, 0, null);
}
}
// Restore to pre-node transform (not the node-transformed state),
// preventing the node's transform from leaking to subsequent siblings.
effectBaseContext.setTransform(preNodeTransform);
if (groupOpacity) {
effectBaseContext.setComposite(savedComposite);
}
effectContext.dispose();
effectBufferInUse = false;
}
this.parentNode = oldParentNode;
this.fontSize = oldFontSize;
this.contextWidth = oldContextWidth;
this.contextHeight = oldContextHeight;
}
private void configureStroke(Node node) {
// These are set during draw for each node's stroke
}
/**
* Compute device-pixel padding for the sub-region effect buffer. Accounts for
* stroke width extent.
*/
private int computeEffectPadding(Node node) {
int pad = 4; // base padding for anti-aliasing and rounding
// Stroke extent
String stroke = node.get("stroke", "none");
if (!"none".equals(stroke)) {
double sw = size(this, node.get("stroke-width", "1"));
AffineTransform xf = context.getTransform();
double sx = Math.sqrt(xf.getScaleX() * xf.getScaleX() + xf.getShearX() * xf.getShearX());
double sy = Math.sqrt(xf.getScaleY() * xf.getScaleY() + xf.getShearY() * xf.getShearY());
pad += (int) Math.ceil(sw * Math.max(sx, sy) / 2) + 1;
}
return pad;
}
private void applyClip(Node node) {
// Clip rect
String[] rectValues = Helpers.clipRect(node.get("clip"));
if (rectValues.length == 4) {
double top = size(this, rectValues[0], "y");
double right = size(this, rectValues[1], "x");
double bottom = size(this, rectValues[2], "y");
double left = size(this, rectValues[3], "x");
double x = size(this, node.get("x"), "x");
double y = size(this, node.get("y"), "y");
double w = size(this, node.get("width"), "x");
double h = size(this, node.get("height"), "y");
context.clip(new Rectangle2D.Double(x + left, y + top, w - left - right, h - top - bottom));
}
// Clip path
String clipPathStr = node.get("clip-path");
if (clipPathStr != null) {
String clipId = UrlHelper.parseUrl(clipPathStr).fragment();
if (clipId != null) {
Node clipNode = this.paths.get(clipId);
if (clipNode != null) {
// Render clip path
GeneralPath savedPath = this.path;
boolean savedStrokeAndFill = this.strokeAndFill;
this.path = new GeneralPath();
this.strokeAndFill = false;
for (Node child : clipNode.children) {
draw(child);
}
if (!this.path.getPathIterator(null).isDone()) {
context.clip(this.path);
}
this.path = savedPath;
this.strokeAndFill = savedStrokeAndFill;
}
}
}
}
/** Write output. Override in subclasses. */
public void finish() throws IOException {
if (context != null) {
context.dispose();
}
}
/** Get the rendered image. */
public BufferedImage getImage() {
return image;
}
/**
* Paint a shape (fill or stroke) while honouring a pending
* {@link #paintTransform}. When a pattern's {@code patternTransform} is active
* the Graphics2D coordinate system is temporarily transformed so that the
* TexturePaint tiling grid is correctly scaled/rotated/skewed.
*/
private void paintWithTransform(Shape shape, boolean fill) {
if (paintTransform != null) {
AffineTransform saved = context.getTransform();
context.transform(paintTransform);
try {
Shape mapped = paintTransform.createInverse().createTransformedShape(shape);
if (fill)
context.fill(mapped);
else
context.draw(mapped);
} catch (java.awt.geom.NoninvertibleTransformException e) {
if (fill)
context.fill(shape);
else
context.draw(shape);
}
context.setTransform(saved);
paintTransform = null;
} else {
if (fill)
context.fill(shape);
else
context.draw(shape);
}
}
private static int getLineCap(String cap) {
if (cap == null)
return BasicStroke.CAP_BUTT;
return switch (cap) {
case "round" -> BasicStroke.CAP_ROUND;
case "square" -> BasicStroke.CAP_SQUARE;
default -> BasicStroke.CAP_BUTT;
};
}
private static int getLineJoin(String join) {
if (join == null)
return BasicStroke.JOIN_MITER;
return switch (join) {
case "round" -> BasicStroke.JOIN_ROUND;
case "bevel" -> BasicStroke.JOIN_BEVEL;
default -> BasicStroke.JOIN_MITER;
};
}
/** Convert RGBA to a clamped AWT Color. */
static Color toAwtColor(Colors.RGBA c) {
return new Color((float) Math.max(0, Math.min(1, c.r())), (float) Math.max(0, Math.min(1, c.g())),
(float) Math.max(0, Math.min(1, c.b())), (float) Math.max(0, Math.min(1, c.a())));
}
}