SvgSaxParser.java

package net.sourceforge.plantuml.svg.parser;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import net.atmp.PixelImage;
import net.sourceforge.plantuml.emoji.ColorResolver;
import net.sourceforge.plantuml.emoji.GrayLevelRange;
import net.sourceforge.plantuml.emoji.UGraphicWithScale;
import net.sourceforge.plantuml.klimt.AffineTransformType;
import net.sourceforge.plantuml.klimt.UStroke;
import net.sourceforge.plantuml.klimt.UTranslate;
import net.sourceforge.plantuml.klimt.awt.PortableImage;
import net.sourceforge.plantuml.klimt.color.HColor;
import net.sourceforge.plantuml.klimt.color.HColors;
import net.sourceforge.plantuml.klimt.color.HColorLinearGradient;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.font.FontConfiguration;
import net.sourceforge.plantuml.klimt.font.FontStyle;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.font.UFont;
import net.sourceforge.plantuml.klimt.font.UFontFace;
import net.sourceforge.plantuml.klimt.font.UFontFactory;
import net.sourceforge.plantuml.klimt.font.UFontStyle;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.shape.TextBlock;
import net.sourceforge.plantuml.klimt.shape.UImage;
import net.sourceforge.plantuml.klimt.shape.UImageSvg;
import net.sourceforge.plantuml.klimt.shape.URectangle;
import net.sourceforge.plantuml.klimt.shape.UText;
import net.sourceforge.plantuml.openiconic.SvgPath;
import net.sourceforge.plantuml.security.SImageIO;
import net.sourceforge.plantuml.utils.Base64Coder;

/**
 * Zero-dependency SAX-based SVG parser using only Java SDK components.
 * 
 * <p>This parser provides a zero-dependency alternative without requiring
 * Apache Batik dependencies. Uses Java's built-in SAX parser (available since Java 1.4).
 * 
 * <p><b>Feature Set (SVG 1.1 Core Subset):</b>
 * <ul>
 *   <li>Basic shapes: rect, circle, ellipse, line, polyline, polygon, path</li>
 *   <li>Text elements with font styling (family, size, weight, style, decoration) and text-anchor alignment</li>
 *   <li>Transforms: translate, rotate, scale, matrix</li>
 *   <li>Gradients: linearGradient (core renderer does not support radial gradients)</li>
 *   <li>Groups with style inheritance</li>
 *   <li>Definitions: defs, symbol, use references</li>
 * </ul>
 * 
 * <p><b>Limitations:</b>
 * <ul>
 *   <li>Feature-frozen at SVG 1.1 core subset</li>
 *   <li>No SVG 2.0 extensions (use a full DOM-based parser for full support)</li>
 *   <li>Radial gradients are not supported (not in core PlantUML renderer)</li>
 *   <li>Linear gradients support multiple stops with stop-opacity</li>
 *   <li>Embedded raster images via data URIs only (PNG/JPEG); no external URLs or embedded SVG</li>
 *   <li>No clipPath, mask, filter, pattern</li>
 *   <li>Text: no tspan, overline, or advanced layout</li>
 *   <li>Numeric font-weight values are reduced to bold/normal only</li>
 *   <li>CSS: No &lt;style&gt; blocks or class selectors (use inline attributes only)</li>
 * </ul>
 * 
 * <p><b>Architecture:</b> Two-pass parsing
 * <ol>
 *   <li>Pass 1: Collect definitions (defs, symbols, gradients)</li>
 *   <li>Pass 2: Render elements, resolve use references, apply transforms</li>
 * </ol>
 * 
 * @see ISvgSpriteParser
 * @since 1.2026.3
 */
public class SvgSaxParser implements ISvgSpriteParser, GrayLevelRange {

    private static final Logger LOG = Logger.getLogger(SvgSaxParser.class.getName());

    private final List<String> svg;

    public SvgSaxParser(String svg) {
        this(Collections.singletonList(svg));
    }

    public SvgSaxParser(List<String> svg) {
        this.svg = svg;
    }

    @Override
    public void drawU(UGraphic ug, double scale, HColor fontColor, HColor forcedColor) {
        final ColorResolver colorResolver = new ColorResolver(fontColor, forcedColor, this);
        UGraphicWithScale ugs = new UGraphicWithScale(ug, colorResolver, scale);

        for (String s : svg) {
            try {
                parseSvg(s, ugs, colorResolver);
            } catch (Exception e) {
                LOG.warning("Failed to parse SVG: " + e.getMessage());
                throw new RuntimeException("Failed to parse SVG", e);
            }
        }
    }

    @Override
    public TextBlock asTextBlock(final HColor fontColor, final HColor forcedColor, final double scale,
            final HColor backColor) {
        final UImageSvg data = new UImageSvg(svg.get(0), scale);
        final double width = data.getWidth();
        final double height = data.getHeight();

        return new TextBlock() {
            public void drawU(UGraphic ug) {
                if (backColor != null)
                    ug.apply(backColor.bg()).apply(backColor)
                            .draw(URectangle.build(calculateDimension(ug.getStringBounder())));
                SvgSaxParser.this.drawU(ug, scale, fontColor, forcedColor);
            }

            public XDimension2D calculateDimension(StringBounder stringBounder) {
                return new XDimension2D(width, height);
            }
        };
    }

    @Override
    public int getMinGrayLevel() {
        return 0;
    }

    @Override
    public int getMaxGrayLevel() {
        return 255;
    }

    /**
     * Parses SVG using two-pass SAX parsing.
     */
    private void parseSvg(String svgContent, UGraphicWithScale ugs, ColorResolver colorResolver) throws Exception {
        SAXParserFactory factory = SAXParserFactory.newInstance();
        factory.setNamespaceAware(true);
        SAXParser parser = factory.newSAXParser();

        // Pass 1: Collect definitions
        DefsCollector defsCollector = new DefsCollector();
        parser.parse(new InputSource(new StringReader(svgContent)), defsCollector);

        // Pass 2: Render elements
        RenderHandler renderer = new RenderHandler(ugs, colorResolver, defsCollector.getDefinitions());
        parser.parse(new InputSource(new StringReader(svgContent)), renderer);
    }

    /**
     * Pass 1: Collects SVG definitions (defs, symbols, gradients) by ID.
     *
     * <p>Stores the raw XML for referenced elements so <code>&lt;use&gt;</code>
     * can re-parse them during render. Gradient stops and definition attributes
     * are also captured for gradient resolution.</p>
     */
    private static class DefsCollector extends DefaultHandler {
        private final Map<String, BufferedElement> definitions = new HashMap<>();
        private int depth = 0;
        private boolean inDefs = false;
        private StringBuilder currentContent = new StringBuilder();
        private String currentId = null;
        private String currentTag = null;
        private Map<String, String> currentAttrs = null;
        private BufferedElement currentElement = null;

        public Map<String, BufferedElement> getDefinitions() {
            return definitions;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attrs) {
            depth++;
            
            if ("defs".equals(qName) || "symbol".equals(qName) || 
                "linearGradient".equals(qName) || "radialGradient".equals(qName)) {
                inDefs = true;
            }

            if (inDefs || "symbol".equals(qName)) {
                String id = attrs.getValue("id");
                if (id != null && !id.isEmpty()) {
                    currentId = id;
                    currentTag = qName;
                    currentContent = new StringBuilder();
                    currentAttrs = new HashMap<>();
                    // Store all attributes for gradient processing
                    for (int i = 0; i < attrs.getLength(); i++) {
                        currentAttrs.put(attrs.getQName(i), attrs.getValue(i));
                    }
                    currentElement = new BufferedElement(qName, "", currentAttrs);
                }
                
                // Handle gradient stops
                if ("stop".equals(qName) && currentElement != null) {
                    String offset = attrs.getValue("offset");
                    String stopColor = attrs.getValue("stop-color");
                    String stopOpacity = attrs.getValue("stop-opacity");
                    final String style = attrs.getValue("style");
                    if (style != null && style.isEmpty() == false) {
                        if (stopColor == null)
                            stopColor = getStyleValue(style, "stop-color");
                        if (stopOpacity == null)
                            stopOpacity = getStyleValue(style, "stop-opacity");
                    }
                    if (offset != null && stopColor != null) {
                        currentElement.stops.add(new GradientStop(offset, stopColor, stopOpacity));
                    }
                }
                
                // Store element as buffered XML
                currentContent.append("<").append(qName);
                for (int i = 0; i < attrs.getLength(); i++) {
                    currentContent.append(" ")
                        .append(attrs.getQName(i))
                        .append("=\"")
                        .append(escapeXml(attrs.getValue(i)))
                        .append("\"");
                }
                currentContent.append(">");
            }
        }

        @Override
        public void endElement(String uri, String localName, String qName) {
            if (inDefs) {
                currentContent.append("</").append(qName).append(">");
                
                if (currentId != null && currentTag != null && currentTag.equals(qName)) {
                    if (currentElement != null) {
                        final BufferedElement buffered = new BufferedElement(currentTag, currentContent.toString(), currentAttrs);
                        buffered.stops.addAll(currentElement.stops);
                        definitions.put(currentId, buffered);
                    }
                    currentId = null;
                    currentTag = null;
                    currentAttrs = null;
                    currentElement = null;
                }
            }

            depth--;
            if (("defs".equals(qName) || "symbol".equals(qName)) && depth == 1) {
                inDefs = false;
            }
        }

        @Override
        public void characters(char[] ch, int start, int length) {
            if (inDefs && currentContent != null) {
                currentContent.append(escapeXml(new String(ch, start, length)));
            }
        }

        private String escapeXml(String text) {
            return text.replace("&", "&amp;")
                      .replace("<", "&lt;")
                      .replace(">", "&gt;")
                      .replace("\"", "&quot;");
        }

        private String getStyleValue(String style, String key) {
            final String[] parts = style.split(";");
            for (String part : parts) {
                final int idx = part.indexOf(':');
                if (idx <= 0)
                    continue;

                final String name = part.substring(0, idx).trim();
                if (name.equalsIgnoreCase(key))
                    return part.substring(idx + 1).trim();
            }
            return null;
        }
    }

    /**
     * Buffered SVG element for definitions.
     */
    private static class BufferedElement {
        final String tagName;
        final String xmlContent;
        final Map<String, String> attributes;
        final List<GradientStop> stops;

        BufferedElement(String tagName, String xmlContent, Map<String, String> attributes) {
            this.tagName = tagName;
            this.xmlContent = xmlContent;
            this.attributes = attributes;
            this.stops = new ArrayList<>();
        }
    }

    /**
     * Gradient stop color information.
     */
    private static class GradientStop {
        final String offset;
        final String color;
        final String opacity;

        GradientStop(String offset, String color, String opacity) {
            this.offset = offset;
            this.color = color;
            this.opacity = opacity;
        }
    }

    /**
     * Pass 2: Renders SVG elements to UGraphic.
     *
     * <p>Consumes SAX events from the SVG stream and translates supported
     * elements into PlantUML drawing primitives. Definition blocks are skipped
     * during this pass and <code>&lt;use&gt;</code> references are resolved by
     * re-parsing the buffered definition XML with the current transform and
     * inherited styling applied.</p>
     */
    private static class RenderHandler extends DefaultHandler {
        private final UGraphicWithScale initialUgs;
        private UGraphicWithScale ugs;
        private final ColorResolver colorResolver;
        private final Map<String, BufferedElement> definitions;
        private final Deque<GroupState> groupStack = new ArrayDeque<>();
        private final List<UGraphicWithScale> ugsStack = new ArrayList<>();
        private int defsDepth = 0;
        
        private boolean inText = false;
        private StringBuilder textContent = new StringBuilder();
        private Attributes textAttrs = null;

        RenderHandler(UGraphicWithScale ugs, ColorResolver colorResolver, Map<String, BufferedElement> definitions) {
            this.initialUgs = ugs;
            this.ugs = ugs;
            this.colorResolver = colorResolver;
            this.definitions = definitions;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attrs) throws SAXException {
            if ("defs".equals(qName)) {
                defsDepth++;
                return;
            }
            if (defsDepth > 0) {
                return;
            }
            LOG.fine("Starting element: " + qName + " with " + attrs.getLength() + " attributes");
            switch (qName) {
                case "svg":
                    // Root element, just continue
                    break;
                case "g":
                    handleGroupStart(attrs);
                    break;
                case "rect":
                    handleRect(attrs);
                    break;
                case "circle":
                    handleCircle(attrs);
                    break;
                case "ellipse":
                    handleEllipse(attrs);
                    break;
                case "line":
                    handleLine(attrs);
                    break;
                case "polyline":
                    handlePolyline(attrs);
                    break;
                case "polygon":
                    handlePolygon(attrs);
                    break;
                case "path":
                    handlePath(attrs);
                    break;
                case "text":
                    inText = true;
                    textContent = new StringBuilder();
                    textAttrs = new AttributesAdapter(attrs);
                    break;
                case "image":
                    handleImage(attrs);
                    break;
                case "use":
                    handleUse(attrs);
                    break;
                case "defs":
                case "symbol":
                case "title":
                case "desc":
                case "metadata":
                case "clipPath":
                case "mask":
                case "filter":
                case "pattern":
                case "marker":
                case "style":
                    // Skip: CSS style blocks not supported (use inline attributes)
                    // Supporting CSS would require a full CSS parser for selectors,
                    // specificity, cascade, and inheritance rules
                    break;
                case "script":
                    // Skip these elements
                    break;
                default:
                    // Unknown element, skip
                    break;
            }
        }

        @Override
        public void endElement(String uri, String localName, String qName) {
            if ("defs".equals(qName) && defsDepth > 0) {
                defsDepth--;
                return;
            }
            if (defsDepth > 0) {
                return;
            }
            LOG.fine("Ending element: " + qName);
            if ("g".equals(qName)) {
                handleGroupEnd();
            } else if ("text".equals(qName) && inText) {
                handleText(textAttrs, textContent.toString());
                inText = false;
                textContent = new StringBuilder();
                textAttrs = null;
            }
        }

        @Override
        public void characters(char[] ch, int start, int length) {
            if (inText) {
                textContent.append(ch, start, length);
            }
        }

        // ---- Group Handling ----

        private void handleGroupStart(Attributes attrs) {
            GroupState gs = new GroupState(attrs);
            groupStack.addFirst(gs);
            ugsStack.add(0, ugs);

            if (gs.transform != null && !gs.transform.isEmpty()) {
                ugs = applyTransform(ugs, gs.transform);
            }
        }

        private void handleGroupEnd() {
            if (!groupStack.isEmpty()) {
                groupStack.removeFirst();
            }
            if (!ugsStack.isEmpty()) {
                ugs = ugsStack.remove(0);
            }
        }

        // ---- Shape Handlers ----

        private void handleRect(Attributes attrs) {
            UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            double x = getDoubleAttr(attrs, "x", 0);
            double y = getDoubleAttr(attrs, "y", 0);
            double width = getDoubleAttr(attrs, "width", 0);
            double height = getDoubleAttr(attrs, "height", 0);

            net.sourceforge.plantuml.klimt.UPath path = net.sourceforge.plantuml.klimt.UPath.none();
            path.moveTo(x, y);
            path.lineTo(x + width, y);
            path.lineTo(x + width, y + height);
            path.lineTo(x, y + height);
            path.lineTo(x, y);

            path = path.affine(elementUgs.getAffineTransform(), elementUgs.getAngle(), elementUgs.getInitialScale());
            elementUgs.draw(path);
        }

        private void handleCircle(Attributes attrs) {
            UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            double cx = getDoubleAttr(attrs, "cx", 0);
            double cy = getDoubleAttr(attrs, "cy", 0);
            double r = getDoubleAttr(attrs, "r", 0);

            net.sourceforge.plantuml.klimt.UPath path = buildEllipsePath(cx, cy, r, r);
            path = path.affine(elementUgs.getAffineTransform(), elementUgs.getAngle(), elementUgs.getInitialScale());
            elementUgs.draw(path);
        }

        private void handleEllipse(Attributes attrs) {
            UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            double cx = getDoubleAttr(attrs, "cx", 0);
            double cy = getDoubleAttr(attrs, "cy", 0);
            double rx = getDoubleAttr(attrs, "rx", 0);
            double ry = getDoubleAttr(attrs, "ry", 0);

            net.sourceforge.plantuml.klimt.UPath path = buildEllipsePath(cx, cy, rx, ry);
            path = path.affine(elementUgs.getAffineTransform(), elementUgs.getAngle(), elementUgs.getInitialScale());
            elementUgs.draw(path);
        }

        private void handleLine(Attributes attrs) {
            UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            double x1 = getDoubleAttr(attrs, "x1", 0);
            double y1 = getDoubleAttr(attrs, "y1", 0);
            double x2 = getDoubleAttr(attrs, "x2", 0);
            double y2 = getDoubleAttr(attrs, "y2", 0);

            net.sourceforge.plantuml.klimt.UPath path = net.sourceforge.plantuml.klimt.UPath.none();
            path.moveTo(x1, y1);
            path.lineTo(x2, y2);

            path = path.affine(elementUgs.getAffineTransform(), elementUgs.getAngle(), elementUgs.getInitialScale());
            elementUgs.draw(path);
        }

        private void handlePolyline(Attributes attrs) {
            handlePolyShape(attrs, false);
        }

        private void handlePolygon(Attributes attrs) {
            handlePolyShape(attrs, true);
        }

        private void handlePolyShape(Attributes attrs, boolean closed) {
            final UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            String pointsStr = attrs.getValue("points");
            if (pointsStr == null || pointsStr.isEmpty()) {
                return;
            }

            String[] pointPairs = pointsStr.trim().split("\\s+");
            net.sourceforge.plantuml.klimt.UPath path = 
                new net.sourceforge.plantuml.klimt.UPath(closed ? "polygon" : "polyline", null);
            
            boolean first = true;
            double firstX = 0;
            double firstY = 0;
            for (String pair : pointPairs) {
                String[] coords = pair.split(",");
                if (coords.length == 2) {
                    try {
                        double x = Double.parseDouble(coords[0].trim());
                        double y = Double.parseDouble(coords[1].trim());
                        if (first) {
                            path.moveTo(x, y);
                            firstX = x;
                            firstY = y;
                            first = false;
                        } else {
                            path.lineTo(x, y);
                        }
                    } catch (NumberFormatException e) {
                        // Skip invalid points
                    }
                }
            }

            if (closed) {
                // NOTE: Keep polygons open to avoid SEG_CLOSE dependency in klimt.
                // path.closePath();
                if (first == false)
                    path.lineTo(firstX, firstY);
            }

            path = path.affine(elementUgs.getAffineTransform(), elementUgs.getAngle(), elementUgs.getInitialScale());
            elementUgs.draw(path);
        }

        private net.sourceforge.plantuml.klimt.UPath buildEllipsePath(double cx, double cy, double rx, double ry) {
            net.sourceforge.plantuml.klimt.UPath path = net.sourceforge.plantuml.klimt.UPath.none();
            path.moveTo(0, ry);
            path.arcTo(rx, ry, 0, 0, 1, rx, 0);
            path.arcTo(rx, ry, 0, 0, 1, 2 * rx, ry);
            path.arcTo(rx, ry, 0, 0, 1, rx, 2 * ry);
            path.arcTo(rx, ry, 0, 0, 1, 0, ry);
            path.lineTo(0, ry);

            return path.translate(cx - rx, cy - ry);
        }

        private void handlePath(Attributes attrs) {
            final UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            String pathData = attrs.getValue("d");
            if (pathData != null && !pathData.isEmpty()) {
                SvgPath svgPath = new SvgPath(pathData, UTranslate.none());
                svgPath.drawMe(elementUgs.getUg(), elementUgs.getAffineTransform());
            }
        }

        private void handleText(Attributes attrs, String content) {
            final UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);
            String fontFamily = getAttrOrStyle(attrs, "font-family", "font-family");
            String fontSize = getAttrOrStyle(attrs, "font-size", "font-size");
            String fontWeight = getAttrOrStyle(attrs, "font-weight", "font-weight");
            String fontStyle = getAttrOrStyle(attrs, "font-style", "font-style");
            String textDecoration = getAttrOrStyle(attrs, "text-decoration", "text-decoration");
            String fillString = getAttrOrStyle(attrs, "fill", "fill");
            String textAnchor = attrs.getValue("text-anchor");
            String xLocation = attrs.getValue("x");
            String yLocation = attrs.getValue("y");

            String textContent = content.trim();
            int fontSizeValue = parseFontSize(fontSize, 12);
            UFontFace face = parseFontFace(fontWeight, fontStyle);

            // Use default font family if not specified
            if (fontFamily == null || fontFamily.isEmpty()) {
                fontFamily = "SansSerif";
            }

            UFont font = UFontFactory.build(fontFamily, face, fontSizeValue);

            HColor textColor = elementUgs.getDefaultColor();
            if (fillString != null && !fillString.isEmpty() && !"none".equals(fillString)) {
                HColor fc = elementUgs.getTrueColor(fillString);
                if (fc != null) {
                    textColor = fc;
                }
            }

            FontConfiguration fontConfig = FontConfiguration.create(font, textColor, textColor, UStroke.simple(), 8);
            fontConfig = applyTextDecoration(fontConfig, textDecoration);

            UText utext = UText.build(textContent, fontConfig);

            double x = xLocation != null && !xLocation.isEmpty() ? Double.parseDouble(xLocation) : 0;
            double y = yLocation != null && !yLocation.isEmpty() ? Double.parseDouble(yLocation) : 0;
            double anchorShift = 0;
            if (textAnchor != null && textAnchor.isEmpty() == false) {
                double textWidth = utext.calculateDimension(elementUgs.getUg().getStringBounder()).getWidth();
                if ("middle".equalsIgnoreCase(textAnchor)) {
                    anchorShift = -textWidth / 2.0;
                } else if ("end".equalsIgnoreCase(textAnchor)) {
                    anchorShift = -textWidth;
                }
            }

            UTranslate textTranslate = new UTranslate(x + anchorShift, y);
            elementUgs.apply(textTranslate).draw(utext);
        }

        private void handleImage(Attributes attrs) {
            final String href = getHref(attrs);
            if (href == null || href.isEmpty())
                return;

            final DataImage dataImage = decodeDataImage(href);
            if (dataImage == null)
                return;

            final PortableImage image = dataImage.image;
            if (image == null)
                return;

            final UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);
            final double x = getDoubleAttr(attrs, "x", 0);
            final double y = getDoubleAttr(attrs, "y", 0);

            final String widthAttr = attrs.getValue("width");
            final String heightAttr = attrs.getValue("height");
            final double width = getDoubleAttr(attrs, "width", image.getWidth());
            final double height = getDoubleAttr(attrs, "height", image.getHeight());

            if (width <= 0 || height <= 0)
                return;

            final double scale = resolveImageScale(widthAttr, heightAttr, width, height, image.getWidth(), image.getHeight());
            if (scale <= 0)
                return;

            if (elementUgs.getUg().matchesProperty("SVG")) {
                final String svgImage = buildSvgImage(href, width, height);
                final UImageSvg imageSvg = new UImageSvg(svgImage, 1.0);
                elementUgs.apply(new UTranslate(x, y)).draw(imageSvg);
                return;
            }

            UImage uimage = new UImage(new PixelImage(image, AffineTransformType.TYPE_BILINEAR));
            if (scale != 1.0) {
                final PortableImage scaled = uimage.getImage(scale);
                uimage = new UImage(new PixelImage(scaled, AffineTransformType.TYPE_BILINEAR));
            }

            elementUgs.apply(new UTranslate(x, y)).draw(uimage);
        }

        private String getHref(Attributes attrs) {
            String href = attrs.getValue("href");
            if (href == null || href.isEmpty())
                href = attrs.getValue("xlink:href");

            return href;
        }

        private double resolveImageScale(String widthAttr, String heightAttr, double width, double height,
                int imageWidth, int imageHeight) {
            if (imageWidth <= 0 || imageHeight <= 0)
                return 1.0;

            final double scaleX = width / imageWidth;
            final double scaleY = height / imageHeight;

            if (widthAttr == null && heightAttr == null)
                return 1.0;

            if (Math.abs(scaleX - scaleY) < 0.0001)
                return scaleX;

            if (widthAttr != null)
                return scaleX;

            return scaleY;
        }

        private DataImage decodeDataImage(String href) {
            final String lowerHref = href.toLowerCase();
            if (lowerHref.startsWith("data:image/svg+xml;base64,")) {
                LOG.fine("Skipping embedded SVG image data URI");
                return null;
            }

            final String prefix = getDataImagePrefix(lowerHref);
            if (prefix == null) {
                LOG.fine("Skipping non-data image href");
                return null;
            }

            final String data = href.substring(prefix.length());
            final byte[] bytes;
            try {
                bytes = Base64Coder.decode(data);
            } catch (IllegalArgumentException e) {
                LOG.warning("Failed to decode embedded image: " + e.getMessage());
                return null;
            }

            try {
                final PortableImage image = SImageIO.read(bytes);
                if (image == null)
                    return null;

                return new DataImage(image);
            } catch (IOException e) {
                LOG.warning("Failed to read embedded image: " + e.getMessage());
                return null;
            }
        }

        private String getDataImagePrefix(String lowerHref) {
            if (lowerHref.startsWith("data:image/png;base64,"))
                return "data:image/png;base64,";

            if (lowerHref.startsWith("data:image/jpeg;base64,"))
                return "data:image/jpeg;base64,";

            if (lowerHref.startsWith("data:image/jpg;base64,"))
                return "data:image/jpg;base64,";

            return null;
        }

        private String buildSvgImage(String href, double width, double height) {
            final String safeHref = href.replace("&", "&amp;").replace("\"", "&quot;");
            return "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
                    + "width=\"" + formatNumber(width) + "\" height=\"" + formatNumber(height) + "\">"
                    + "<image x=\"0\" y=\"0\" width=\"" + formatNumber(width) + "\" height=\""
                    + formatNumber(height) + "\" xlink:href=\"" + safeHref + "\"/>"
                    + "</svg>";
        }

        private String formatNumber(double value) {
            if (value == Math.rint(value))
                return Long.toString(Math.round(value));

            return Double.toString(value);
        }

        private void handleUse(Attributes attrs) {
            String href = attrs.getValue("href");
            if (href == null || href.isEmpty()) {
                href = attrs.getValue("xlink:href");
            }
            if (href == null || href.isEmpty() || !href.startsWith("#")) {
                return;
            }

            String refId = href.substring(1);
            BufferedElement referenced = definitions.get(refId);
            if (referenced == null) {
                LOG.warning("Referenced element not found: " + refId);
                return;
            }

            // Apply positioning from <use> element
            double x = getDoubleAttr(attrs, "x", 0);
            double y = getDoubleAttr(attrs, "y", 0);
            double width = getDoubleAttr(attrs, "width", -1);
            double height = getDoubleAttr(attrs, "height", -1);

            UGraphicWithScale elementUgs = applyStyleAndTransform(attrs, ugs);

            if (x != 0 || y != 0) {
                double scalex = elementUgs.getAffineTransform().getScaleX();
                double scaley = elementUgs.getAffineTransform().getScaleY();
                UTranslate translate = new UTranslate(x * scalex, y * scaley);
                elementUgs = elementUgs.apply(translate);
            }

            renderUseReference(refId, referenced, elementUgs, width, height);
        }

        private void renderUseReference(String refId, BufferedElement referenced, UGraphicWithScale elementUgs,
                double width, double height) {
            if (referenced.xmlContent == null || referenced.xmlContent.isEmpty()) {
                LOG.fine("Referenced element has no buffered content: " + refId);
                return;
            }

            if ("symbol".equals(referenced.tagName)) {
                elementUgs = applySymbolViewport(referenced, elementUgs, width, height);
            }

            final String wrapped = wrapSvgFragment(referenced.xmlContent);
            parseSvgFragment(wrapped, elementUgs);
        }

        private UGraphicWithScale applySymbolViewport(BufferedElement referenced, UGraphicWithScale elementUgs,
                double width, double height) {
            final String viewBox = referenced.attributes.get("viewBox");
            if (viewBox == null || viewBox.isEmpty()) {
                return elementUgs;
            }

            final double[] values = parseNumberList(viewBox);
            if (values.length < 4) {
                return elementUgs;
            }

            final double minX = values[0];
            final double minY = values[1];
            final double vbWidth = values[2];
            final double vbHeight = values[3];

            if (vbWidth == 0 || vbHeight == 0) {
                return elementUgs;
            }

            final double scaleX = width > 0 ? width / vbWidth : 1.0;
            final double scaleY = height > 0 ? height / vbHeight : 1.0;

            elementUgs = elementUgs.applyTranslate(-minX, -minY);
            elementUgs = elementUgs.applyScale(scaleX, scaleY);
            return elementUgs;
        }

        private String wrapSvgFragment(String fragment) {
            return "<svg xmlns=\"http://www.w3.org/2000/svg\">" + fragment + "</svg>";
        }

        private void parseSvgFragment(String svgFragment, UGraphicWithScale elementUgs) {
            try {
                SAXParserFactory factory = SAXParserFactory.newInstance();
                factory.setNamespaceAware(true);
                SAXParser parser = factory.newSAXParser();
                RenderHandler renderer = new RenderHandler(elementUgs, colorResolver, definitions);
                parser.parse(new InputSource(new StringReader(svgFragment)), renderer);
            } catch (Exception e) {
                LOG.warning("Failed to parse <use> reference: " + e.getMessage());
            }
        }

        // ---- Style and Transform Helpers ----

        /**
         * Extract a color or gradient from a gradient definition.
         * For linear gradients, returns an HColorGradient if possible.
         * For radial gradients or when gradient creation fails, returns the first stop color.
         */
        private HColor extractGradientColor(String gradientId) {
            final BufferedElement gradientElement = definitions.get(gradientId);
            if (gradientElement == null) {
                LOG.fine("Gradient not found: " + gradientId);
                return null;
            }

            final String tagName = gradientElement.tagName;
            LOG.fine("Extracting gradient: " + gradientId + " (tagName=" + tagName + ", stops=" + gradientElement.stops.size() + ")");

            if ("linearGradient".equals(tagName)) {
                final List<HColorLinearGradient.Stop> stops = buildGradientStops(gradientElement.stops);
                if (stops.size() >= 2) {
                    final boolean userSpaceOnUse = "userSpaceOnUse".equals(gradientElement.attributes.get("gradientUnits"));
                    final HColorLinearGradient.SpreadMethod spreadMethod =
                            parseSpreadMethod(gradientElement.attributes.get("spreadMethod"));
                    final double x1 = parsePercentOrNumber(gradientElement.attributes.get("x1"), 0.0);
                    final double y1 = parsePercentOrNumber(gradientElement.attributes.get("y1"), 0.0);
                    final double x2 = parsePercentOrNumber(gradientElement.attributes.get("x2"), 1.0);
                    final double y2 = parsePercentOrNumber(gradientElement.attributes.get("y2"), 0.0);
                    return new HColorLinearGradient(x1, y1, x2, y2, userSpaceOnUse, spreadMethod, stops);
                }
                if (gradientElement.stops.size() > 0) {
                    final HColor fallback = getStopColor(gradientElement.stops.get(0));
                    if (fallback != null)
                        return fallback;
                }
            }

            if ("radialGradient".equals(tagName)) {
                if (gradientElement.stops.size() > 0) {
                    final HColor fallback = getStopColor(gradientElement.stops.get(0));
                    if (fallback != null)
                        return fallback;
                }
            }

            return null;
        }

        private List<HColorLinearGradient.Stop> buildGradientStops(List<GradientStop> stops) {
            final List<HColorLinearGradient.Stop> result = new ArrayList<HColorLinearGradient.Stop>();
            for (GradientStop stop : stops) {
                final HColor color = getStopColor(stop);
                if (color == null)
                    continue;

                final double offset = parseOffset(stop.offset);
                final double opacity = parseOpacity(stop.opacity);
                result.add(new HColorLinearGradient.Stop(offset, color, opacity));
            }
            return result;
        }

        private HColor getStopColor(GradientStop stop) {
            if (stop == null)
                return null;
            if (stop.color == null || stop.color.isEmpty())
                return null;
            if ("none".equalsIgnoreCase(stop.color))
                return null;
            return ugs.getTrueColor(stop.color);
        }

        private double parseOffset(String value) {
            if (value == null || value.isEmpty())
                return 0.0;

            try {
                if (value.endsWith("%"))
                    return clamp01(Double.parseDouble(value.substring(0, value.length() - 1)) / 100.0);
                return clamp01(Double.parseDouble(value));
            } catch (NumberFormatException e) {
                return 0.0;
            }
        }

        private double parseOpacity(String value) {
            if (value == null || value.isEmpty())
                return 1.0;

            try {
                return clamp01(Double.parseDouble(value));
            } catch (NumberFormatException e) {
                return 1.0;
            }
        }

        private double clamp01(double value) {
            if (value < 0.0)
                return 0.0;
            if (value > 1.0)
                return 1.0;
            return value;
        }

        private HColorLinearGradient.SpreadMethod parseSpreadMethod(String value) {
            if (value == null || value.isEmpty())
                return HColorLinearGradient.SpreadMethod.PAD;

            final String lower = value.toLowerCase();
            if ("reflect".equals(lower))
                return HColorLinearGradient.SpreadMethod.REFLECT;
            if ("repeat".equals(lower))
                return HColorLinearGradient.SpreadMethod.REPEAT;

            return HColorLinearGradient.SpreadMethod.PAD;
        }
        
        /**
         * Determine the PlantUML gradient policy character from SVG linearGradient attributes.
         * Maps SVG x1/y1/x2/y2 to PlantUML's | - \\ / policy.
         */
        private char determineGradientPolicy(Map<String, String> attrs) {
            final String x1 = attrs.get("x1");
            final String y1 = attrs.get("y1");
            final String x2 = attrs.get("x2");
            final String y2 = attrs.get("y2");
            
            // Parse percentage or numeric values
            final double dx1 = parsePercentOrNumber(x1, 0.0);
            final double dy1 = parsePercentOrNumber(y1, 0.0);
            final double dx2 = parsePercentOrNumber(x2, 1.0);
            final double dy2 = parsePercentOrNumber(y2, 0.0);
            
            // Determine direction based on differences
            final double deltaX = dx2 - dx1;
            final double deltaY = dy2 - dy1;
            
            // Horizontal gradient: x changes, y constant
            if (Math.abs(deltaY) < 0.1 && Math.abs(deltaX) > 0.5) {
                return '|';  // left to right
            }
            
            // Vertical gradient: y changes, x constant
            if (Math.abs(deltaX) < 0.1 && Math.abs(deltaY) > 0.5) {
                return '-';  // top to bottom
            }
            
            // Diagonal: both change
            if (Math.abs(deltaX) > 0.3 && Math.abs(deltaY) > 0.3) {
                // Determine diagonal direction
                if (deltaX * deltaY > 0) {
                    return '/';  // top-left to bottom-right
                } else {
                    return '\\'; // bottom-left to top-right
                }
            }
            
            // Default to horizontal
            return '|';
        }
        
        /**
         * Parse a percentage value (e.g., "50%") or number into a 0-1 range.
         */
        private double parsePercentOrNumber(String value, double defaultValue) {
            if (value == null || value.isEmpty()) {
                return defaultValue;
            }
            try {
                if (value.endsWith("%")) {
                    return Double.parseDouble(value.substring(0, value.length() - 1)) / 100.0;
                } else {
                    return Double.parseDouble(value);
                }
            } catch (NumberFormatException e) {
                return defaultValue;
            }
        }

        private UGraphicWithScale applyStyleAndTransform(Attributes attrs, UGraphicWithScale ugs) {
            String transform = attrs.getValue("transform");
            if (transform != null && !transform.isEmpty()) {
                ugs = applyTransform(ugs, transform);
            }

            String fill = getAttrOrStyle(attrs, "fill", "fill");
            String stroke = getAttrOrStyle(attrs, "stroke", "stroke");
            String strokeWidth = getAttrOrStyle(attrs, "stroke-width", "stroke-width");

            if (strokeWidth != null && !strokeWidth.isEmpty()) {
                try {
                    double scale = ugs.getInitialScale();
                    ugs = ugs.apply(UStroke.withThickness(scale * Double.parseDouble(strokeWidth)));
                } catch (NumberFormatException ex) {
                    // ignore
                }
            }

            if (stroke != null && !stroke.isEmpty()) {
                HColor sc = ugs.getTrueColor(stroke);
                if (sc != null) {
                    ugs = ugs.apply(sc);
                }
                if (fill == null || fill.isEmpty()) {
                    ugs = ugs.apply(ugs.getDefaultColor().bg());
                }
            }

            if (fill != null && !fill.isEmpty()) {
                if ("none".equals(fill)) {
                    ugs = ugs.apply(HColors.none().bg());
                } else if (fill.startsWith("url(#")) {
                    // Extract gradient reference: url(#Gradient) -> Gradient
                    final String gradientId = fill.substring(5, fill.length() - 1);
                    LOG.fine("Detected gradient fill: " + gradientId);
                    final HColor gradientColor = extractGradientColor(gradientId);
                    if (gradientColor != null) {
                        LOG.fine("Applied gradient color: " + gradientColor);
                        // If no stroke specified, also set as foreground
                        if (stroke == null || stroke.isEmpty()) {
                            ugs = ugs.apply(gradientColor);
                        }
                        // Always set as background/fill
                        ugs = ugs.apply(gradientColor.bg());
                    } else {
                        LOG.warning("Failed to extract gradient color for: " + gradientId);
                    }
                } else {
                    HColor fc = ugs.getTrueColor(fill);
                    if (fc != null) {
                        if (stroke == null || stroke.isEmpty()) {
                            ugs = ugs.apply(fc);
                        }
                        ugs = ugs.apply(fc.bg());
                    }
                }
            }

            return ugs;
        }

        private String getAttrOrStyle(Attributes attrs, String attrName, String styleKey) {
            final String styleValue = getStyleValue(attrs, styleKey);
            if (styleValue != null && styleValue.isEmpty() == false)
                return styleValue;

            return attrs.getValue(attrName);
        }

        private String getStyleValue(Attributes attrs, String key) {
            final String style = attrs.getValue("style");
            if (style == null || style.isEmpty())
                return null;

            final String[] parts = style.split(";");
            for (String part : parts) {
                final int idx = part.indexOf(':');
                if (idx <= 0)
                    continue;

                final String name = part.substring(0, idx).trim();
                if (name.equalsIgnoreCase(key))
                    return part.substring(idx + 1).trim();
            }
            return null;
        }

        // ---- Transform Parsing (Shared DOM/SAX helpers) ----

        private static final Pattern P_TRANSFORM_OP = Pattern.compile("(translate|rotate|scale|matrix)\\s*\\(([^)]*)\\)");

        private UGraphicWithScale applyTransform(UGraphicWithScale ugs, String transform) {
            if (transform == null || transform.isEmpty()) {
                return ugs;
            }

            Matcher matcher = P_TRANSFORM_OP.matcher(transform);
            while (matcher.find()) {
                String op = matcher.group(1);
                double[] values = parseNumberList(matcher.group(2));

                if ("translate".equals(op)) {
                    double tx = values.length > 0 ? values[0] : 0;
                    double ty = values.length > 1 ? values[1] : 0;
                    ugs = ugs.applyTranslate(tx, ty);
                } else if ("scale".equals(op)) {
                    double sx = values.length > 0 ? values[0] : 1;
                    double sy = values.length > 1 ? values[1] : sx;
                    if (sx == sy) {
                        ugs = ugs.applyScale(sx, sy);
                    } else {
                        ugs = ugs.applyMatrix(sx, 0, 0, sy, 0, 0);
                    }
                } else if ("rotate".equals(op)) {
                    double angle = values.length > 0 ? values[0] : 0;
                    double cx = values.length > 2 ? values[1] : 0;
                    double cy = values.length > 2 ? values[2] : 0;
                    ugs = ugs.applyRotate(angle, cx, cy);
                } else if ("matrix".equals(op)) {
                    if (values.length >= 6) {
                        ugs = ugs.applyMatrix(values[0], values[1], values[2], values[3], values[4], values[5]);
                    }
                }
            }

            return ugs;
        }

        private double[] parseNumberList(String raw) {
            if (raw == null || raw.trim().isEmpty()) {
                return new double[0];
            }
            String[] parts = raw.trim().split("[,\\s]+");
            double[] values = new double[parts.length];
            int idx = 0;
            for (String part : parts) {
                if (part.isEmpty()) {
                    continue;
                }
                try {
                    values[idx++] = Double.parseDouble(part);
                } catch (NumberFormatException e) {
                    // ignore invalid numbers
                }
            }
            if (idx == values.length) {
                return values;
            }
            double[] trimmed = new double[idx];
            System.arraycopy(values, 0, trimmed, 0, idx);
            return trimmed;
        }

        // ---- Font/Text Helpers (Shared DOM/SAX helpers) ----

        private static int parseFontSize(String fontSizeStr, int defaultSize) {
            if (fontSizeStr == null || fontSizeStr.isEmpty()) {
                return defaultSize;
            }

            try {
                String cleaned = fontSizeStr.trim().replaceAll("(?i)(px|pt|em|rem)$", "");
                return Integer.parseInt(cleaned);
            } catch (NumberFormatException e) {
                return defaultSize;
            }
        }

        /**
         * Parses {@code font-weight} and {@code font-style} SVG/CSS attribute values
         * into a {@link UFontFace} carrying the full CSS weight (100–900) and the
         * italic axis.
         *
         * <p>Weight keywords ({@code normal}, {@code bold}, {@code lighter},
         * {@code bolder}) and numeric values (100–900) are all delegated to
         * {@link UFontFace#fromCssWeight(String)}.  The italic axis is set when
         * {@code font-style} is {@code italic} or {@code oblique}.
         *
         * @param fontWeight the {@code font-weight} attribute/style value, may be {@code null}
         * @param fontStyle  the {@code font-style} attribute/style value, may be {@code null}
         * @return a {@link UFontFace} for use with
         *         {@link UFontFactory#build(String, UFontFace, int)}
         */
        private static UFontFace parseFontFace(String fontWeight, String fontStyle) {
            UFontFace face = UFontFace.normal();

            if (fontWeight != null && !fontWeight.isEmpty()) {
                final UFontFace wf = UFontFace.fromCssWeight(fontWeight.trim());
                if (wf != null)
                    face = face.withWeight(wf.getCssWeight());
            }

            if (fontStyle != null && !fontStyle.isEmpty()) {
                final String s = fontStyle.trim();
                if ("italic".equalsIgnoreCase(s) || "oblique".equalsIgnoreCase(s))
                    face = face.withStyle(UFontStyle.ITALIC);
            }

            return face;
        }

        private static FontConfiguration applyTextDecoration(FontConfiguration fontConfig, String textDecoration) {
            if (textDecoration == null || textDecoration.isEmpty() || "none".equalsIgnoreCase(textDecoration)) {
                return fontConfig;
            }

            if (textDecoration.contains("underline")) {
                fontConfig = fontConfig.add(FontStyle.UNDERLINE);
            }

            if (textDecoration.contains("line-through")) {
                fontConfig = fontConfig.add(FontStyle.STRIKE);
            }

            return fontConfig;
        }

        // ---- Utility Methods ----

        private double getDoubleAttr(Attributes attrs, String name, double defaultValue) {
            String value = attrs.getValue(name);
            if (value == null || value.isEmpty()) {
                return defaultValue;
            }
            try {
                return Double.parseDouble(value);
            } catch (NumberFormatException e) {
                return defaultValue;
            }
        }
    }

    /**
     * Group state holder for nested groups.
     */
    private static class GroupState {
        final String id;
        final String className;
        final String transform;

        GroupState(Attributes attrs) {
            this.id = attrs.getValue("id");
            this.className = attrs.getValue("class");
            this.transform = attrs.getValue("transform");
        }
    }

    private static class DataImage {
        private final PortableImage image;

        private DataImage(PortableImage image) {
            this.image = image;
        }
    }

    /**
     * Adapter to make SAX Attributes persist beyond the event callback.
     */
    private static class AttributesAdapter implements Attributes {
        private final Map<String, String> attrs = new HashMap<>();

        AttributesAdapter(Attributes source) {
            for (int i = 0; i < source.getLength(); i++) {
                attrs.put(source.getQName(i), source.getValue(i));
            }
        }

        @Override
        public int getLength() {
            return attrs.size();
        }

        @Override
        public String getURI(int index) {
            return "";
        }

        @Override
        public String getLocalName(int index) {
            return getQName(index);
        }

        @Override
        public String getQName(int index) {
            return new ArrayList<>(attrs.keySet()).get(index);
        }

        @Override
        public String getType(int index) {
            return "CDATA";
        }

        @Override
        public String getValue(int index) {
            return attrs.get(getQName(index));
        }

        @Override
        public int getIndex(String uri, String localName) {
            return new ArrayList<>(attrs.keySet()).indexOf(localName);
        }

        @Override
        public int getIndex(String qName) {
            return new ArrayList<>(attrs.keySet()).indexOf(qName);
        }

        @Override
        public String getType(String uri, String localName) {
            return "CDATA";
        }

        @Override
        public String getType(String qName) {
            return "CDATA";
        }

        @Override
        public String getValue(String uri, String localName) {
            return attrs.get(localName);
        }

        @Override
        public String getValue(String qName) {
            return attrs.get(qName);
        }
    }
}