TextDrawer.java

package io.brunoborges.jairosvg.draw;

import java.awt.Font;
import java.awt.Shape;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.font.LineMetrics;
import java.awt.geom.AffineTransform;
import java.awt.geom.GeneralPath;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import io.brunoborges.jairosvg.dom.Node;
import io.brunoborges.jairosvg.dom.SvgFont;
import io.brunoborges.jairosvg.surface.Surface;
import io.brunoborges.jairosvg.util.UrlHelper;

import static io.brunoborges.jairosvg.util.Helpers.*;

/**
 * SVG text rendering. Port of CairoSVG text.py
 */
public final class TextDrawer {

    private static final Pattern NUMERIC_PATTERN = Pattern.compile("\\d+");
    private static final double FLATTEN_TOLERANCE = 0.5;

    private static final Map<String, Font> FONT_CACHE = new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, Font> eldest) {
            return size() > 64;
        }
    };

    private TextDrawer() {
    }

    /** Extract the primary font family name from a font-family attribute value. */
    private static String parseFontFamily(Node node) {
        return node.get("font-family", "SansSerif").split(",")[0].strip().replace("'", "").replace("\"", "");
    }

    /** Draw a text node. */
    public static void text(Surface surface, Node node) {
        text(surface, node, false);
    }

    /** Draw a text node, optionally as filled text. */
    public static void text(Surface surface, Node node, boolean drawAsText) {
        String fontFamily = parseFontFamily(node);

        // Check for SVG font match before mapping to AWT fonts
        SvgFont svgFont = surface.fonts.get(fontFamily);

        // Map common generic font families (for AWT fallback)
        fontFamily = switch (fontFamily.toLowerCase()) {
            case "sans-serif" -> "SansSerif";
            case "serif" -> "Serif";
            case "monospace" -> "Monospaced";
            default -> fontFamily;
        };

        int fontStyle = Font.PLAIN;
        String styleStr = node.get("font-style", "normal");
        if ("italic".equals(styleStr) || "oblique".equals(styleStr)) {
            fontStyle |= Font.ITALIC;
        }

        String weightStr = node.get("font-weight", "normal");
        if ("bold".equals(weightStr) || "bolder".equals(weightStr)) {
            fontStyle |= Font.BOLD;
        } else if (NUMERIC_PATTERN.matcher(weightStr).matches() && Integer.parseInt(weightStr) >= 550) {
            fontStyle |= Font.BOLD;
        }

        String fontKey = fontFamily + "|" + fontStyle + "|" + (float) surface.fontSize;
        Font font = FONT_CACHE.get(fontKey);
        if (font == null) {
            font = new Font(fontFamily, fontStyle, 1).deriveFont((float) surface.fontSize);
            FONT_CACHE.put(fontKey, font);
        }

        FontRenderContext frc = surface.context.getFontRenderContext();

        String textContent = node.text;
        // Whitespace-only text between child elements (e.g. tspan) should be ignored
        if (textContent != null && textContent.isBlank() && !node.children.isEmpty()) {
            textContent = null;
        }
        if (textContent == null || textContent.isEmpty()) {
            // Set initial cursor position from this node's x/y
            String parentX = node.get("x");
            String parentY = node.get("y");
            String parentDx = node.get("dx");
            String parentDy = node.get("dy");

            if (parentX != null)
                surface.cursorPosition[0] = size(surface, parentX.split("\\s+")[0], "x");
            if (parentY != null)
                surface.cursorPosition[1] = size(surface, parentY.split("\\s+")[0], "y");
            if (parentDx != null)
                surface.cursorPosition[0] += size(surface, parentDx.split("\\s+")[0], "x");
            if (parentDy != null)
                surface.cursorPosition[1] += size(surface, parentDy.split("\\s+")[0], "y");

            // Adjust cursor for text-anchor using total width of all children
            String textAnchor = node.get("text-anchor");
            if (textAnchor != null && ("middle".equals(textAnchor) || "end".equals(textAnchor))) {
                double totalWidth = measureChildrenWidth(surface, node, frc);
                if ("middle".equals(textAnchor)) {
                    surface.cursorPosition[0] -= totalWidth / 2;
                } else {
                    surface.cursorPosition[0] -= totalWidth;
                }
            }

            return;
        }

        // Parse position attributes
        double startX = 0, startY = 0;
        String xStr = node.get("x");
        String yStr = node.get("y");
        String dxStr = node.get("dx");
        String dyStr = node.get("dy");

        if (xStr != null)
            startX = size(surface, xStr.split("\\s+")[0], "x");
        else
            startX = surface.cursorPosition[0];

        if (yStr != null)
            startY = size(surface, yStr.split("\\s+")[0], "y");
        else
            startY = surface.cursorPosition[1];

        if (dxStr != null)
            startX += size(surface, dxStr.split("\\s+")[0], "x");
        if (dyStr != null)
            startY += size(surface, dyStr.split("\\s+")[0], "y");

        // Text anchor alignment (only when node has its own x position)
        String textAnchor = node.get("text-anchor");
        if (textAnchor != null && xStr != null) {
            double textWidth = svgFont != null
                    ? measureSvgFontWidth(svgFont, textContent, surface.fontSize)
                    : font.getStringBounds(textContent, frc).getWidth();
            if ("middle".equals(textAnchor)) {
                startX -= textWidth / 2;
            } else if ("end".equals(textAnchor)) {
                startX -= textWidth;
            }
        }

        // Apply letter spacing
        double letterSpacing = size(surface, node.get("letter-spacing"));
        double textWidth = 0;

        if (svgFont == null && "textPath".equals(node.tag)
                && drawTextPath(surface, node, font, frc, textContent, letterSpacing)) {
            return;
        }

        if (svgFont != null) {
            // Render using SVG font glyphs as paths (greedy longest-match for multi-char
            // unicode)
            double curX = startX;
            int i = 0;
            while (i < textContent.length()) {
                SvgFont.GlyphMatch match = svgFont.getGlyph(textContent, i);
                SvgFont.Glyph glyph = match.glyph();
                if (glyph != null) {
                    java.awt.geom.GeneralPath glyphPath = svgFont.buildGlyphPath(glyph, surface.fontSize, curX, startY);
                    if (glyphPath != null) {
                        surface.path.append(glyphPath, false);
                    }
                }
                curX += svgFont.getAdvance(glyph, surface.fontSize) + letterSpacing;
                i += match.charsConsumed();
            }
            surface.cursorPosition[0] = curX;
            textWidth = curX - startX;
        } else if (letterSpacing != 0) {
            double curX = startX;
            for (int i = 0; i < textContent.length(); i++) {
                String ch = String.valueOf(textContent.charAt(i));
                if (!ch.isBlank()) {
                    if (drawAsText) {
                        surface.context.setFont(font);
                        surface.context.drawString(ch, (float) curX, (float) startY);
                    } else {
                        GlyphVector gv = font.createGlyphVector(frc, ch);
                        surface.path.append(gv.getOutline((float) curX, (float) startY), false);
                    }
                }
                Rectangle2D charBounds = font.getStringBounds(ch, frc);
                curX += charBounds.getWidth() + letterSpacing;
            }
            surface.cursorPosition[0] = curX;
            textWidth = curX - startX - letterSpacing;
        } else {
            if (drawAsText) {
                surface.context.setFont(font);
                surface.context.drawString(textContent, (float) startX, (float) startY);
            } else {
                GlyphVector gv = font.createGlyphVector(frc, textContent);
                surface.path.append(gv.getOutline((float) startX, (float) startY), false);
            }
            Rectangle2D bounds = font.getStringBounds(textContent, frc);
            surface.cursorPosition[0] = startX + bounds.getWidth();
            textWidth = bounds.getWidth();
        }

        String textDecoration = node.get("text-decoration");
        String normalizedTextDecoration = textDecoration == null ? null : textDecoration.strip();
        if (svgFont == null && textWidth > 0 && normalizedTextDecoration != null
                && !"none".equals(normalizedTextDecoration)) {
            LineMetrics lineMetrics = font.getLineMetrics(textContent, frc);
            double decorationThickness = lineMetrics.getUnderlineThickness();
            for (String decoration : normalizedTextDecoration.split("\\s+")) {
                double decorationY = switch (decoration) {
                    case "underline" -> startY + lineMetrics.getUnderlineOffset();
                    case "overline" -> startY - lineMetrics.getAscent();
                    case "line-through" -> startY + lineMetrics.getStrikethroughOffset();
                    default -> Double.NaN;
                };
                if (!Double.isNaN(decorationY)) {
                    surface.path.append(new Rectangle2D.Double(startX, decorationY - decorationThickness / 2.0,
                            textWidth, decorationThickness), false);
                }
            }
        }

        surface.cursorPosition[1] = startY;
    }

    private static boolean drawTextPath(Surface surface, Node node, Font font, FontRenderContext frc,
            String textContent, double letterSpacing) {
        String href = node.getHref();
        if (href == null || href.isEmpty()) {
            return false;
        }

        String pathId = UrlHelper.parseUrl(href).fragment();
        if (pathId == null || pathId.isEmpty()) {
            return false;
        }

        Node pathNode = Defs.findNodeById(surface.rootNode, pathId);
        if (pathNode == null || !"path".equals(pathNode.tag)) {
            return false;
        }

        GeneralPath savedPath = surface.path;
        GeneralPath textPath = new GeneralPath();
        surface.path = textPath;
        PathDrawer.path(surface, pathNode);
        surface.path = savedPath;

        List<double[]> segments = flattenPathSegments(textPath);
        if (segments.isEmpty()) {
            return false;
        }

        double totalLength = 0;
        for (double[] segment : segments) {
            totalLength += segment[4];
        }

        double distance = parseStartOffset(surface, node.get("startOffset"), totalLength);
        Point2D cursorPoint = pointAtDistance(segments, distance);
        for (int i = 0; i < textContent.length(); i++) {
            String ch = String.valueOf(textContent.charAt(i));
            GlyphVector gv = font.createGlyphVector(frc, ch);
            double advance = gv.getGlyphMetrics(0).getAdvance() + letterSpacing;

            Point2D point = pointAtDistance(segments, distance);
            Point2D tangent = tangentAtDistance(segments, distance);
            if (point == null || tangent == null) {
                break;
            }
            cursorPoint = point;

            if (!ch.isBlank()) {
                double angle = Math.atan2(tangent.getY(), tangent.getX());
                AffineTransform placement = new AffineTransform();
                placement.translate(point.getX(), point.getY());
                placement.rotate(angle);
                surface.path.append(placement.createTransformedShape(gv.getOutline()), false);
            }

            distance += advance;
            if (distance > totalLength) {
                distance = totalLength;
                break;
            }
        }

        if (cursorPoint != null) {
            surface.cursorPosition[0] = cursorPoint.getX();
            surface.cursorPosition[1] = cursorPoint.getY();
        }
        return true;
    }

    private static List<double[]> flattenPathSegments(Shape shape) {
        List<double[]> segments = new ArrayList<>();
        PathIterator iterator = shape.getPathIterator(null, FLATTEN_TOLERANCE);
        double[] coords = new double[6];
        double moveX = 0;
        double moveY = 0;
        double prevX = 0;
        double prevY = 0;
        while (!iterator.isDone()) {
            int type = iterator.currentSegment(coords);
            switch (type) {
                case PathIterator.SEG_MOVETO -> {
                    moveX = coords[0];
                    moveY = coords[1];
                    prevX = coords[0];
                    prevY = coords[1];
                }
                case PathIterator.SEG_LINETO -> {
                    double len = Point2D.distance(prevX, prevY, coords[0], coords[1]);
                    if (len > 0) {
                        segments.add(new double[]{prevX, prevY, coords[0], coords[1], len});
                    }
                    prevX = coords[0];
                    prevY = coords[1];
                }
                case PathIterator.SEG_CLOSE -> {
                    double len = Point2D.distance(prevX, prevY, moveX, moveY);
                    if (len > 0) {
                        segments.add(new double[]{prevX, prevY, moveX, moveY, len});
                    }
                    prevX = moveX;
                    prevY = moveY;
                }
                default -> {
                }
            }
            iterator.next();
        }
        return segments;
    }

    private static Point2D pointAtDistance(List<double[]> segments, double distance) {
        if (segments.isEmpty()) {
            return null;
        }
        double remaining = Math.max(0, distance);
        for (double[] segment : segments) {
            if (remaining <= segment[4]) {
                double t = segment[4] == 0 ? 0 : remaining / segment[4];
                double x = segment[0] + (segment[2] - segment[0]) * t;
                double y = segment[1] + (segment[3] - segment[1]) * t;
                return new Point2D.Double(x, y);
            }
            remaining -= segment[4];
        }
        double[] last = segments.getLast();
        return new Point2D.Double(last[2], last[3]);
    }

    private static Point2D tangentAtDistance(List<double[]> segments, double distance) {
        if (segments.isEmpty()) {
            return null;
        }
        double remaining = Math.max(0, distance);
        for (double[] segment : segments) {
            if (remaining <= segment[4]) {
                return new Point2D.Double(segment[2] - segment[0], segment[3] - segment[1]);
            }
            remaining -= segment[4];
        }
        double[] last = segments.getLast();
        return new Point2D.Double(last[2] - last[0], last[3] - last[1]);
    }

    private static double parseStartOffset(Surface surface, String startOffset, double totalLength) {
        if (startOffset == null || startOffset.isBlank()) {
            return 0;
        }
        String normalized = startOffset.strip();
        if (normalized.endsWith("%")) {
            try {
                return Math.max(0,
                        totalLength * Double.parseDouble(normalized.substring(0, normalized.length() - 1)) / 100.0);
            } catch (NumberFormatException e) {
                return 0;
            }
        }
        return Math.max(0, size(surface, normalized, "x"));
    }

    /** Measure total text width of all children for text-anchor calculation. */
    private static double measureChildrenWidth(Surface surface, Node parent, FontRenderContext frc) {
        double totalWidth = 0;
        for (Node child : parent.children) {
            if (child.text != null && !child.text.isEmpty()) {
                String family = parseFontFamily(child);
                SvgFont svgF = surface.fonts.get(family);
                if (svgF != null) {
                    totalWidth += measureSvgFontWidth(svgF, child.text, surface.fontSize);
                } else {
                    totalWidth += resolveFont(surface, child).getStringBounds(child.text, frc).getWidth();
                }
            }
            if (child.children != null && !child.children.isEmpty()) {
                totalWidth += measureChildrenWidth(surface, child, frc);
            }
        }
        return totalWidth;
    }

    /** Measure the width of text rendered with an SVG font. */
    private static double measureSvgFontWidth(SvgFont svgFont, String text, double fontSize) {
        double width = 0;
        int i = 0;
        while (i < text.length()) {
            SvgFont.GlyphMatch match = svgFont.getGlyph(text, i);
            width += svgFont.getAdvance(match.glyph(), fontSize);
            i += match.charsConsumed();
        }
        return width;
    }

    /** Resolve the Font for a given node based on its font attributes. */
    private static Font resolveFont(Surface surface, Node node) {
        String fontFamily = parseFontFamily(node);
        fontFamily = switch (fontFamily.toLowerCase()) {
            case "sans-serif" -> "SansSerif";
            case "serif" -> "Serif";
            case "monospace" -> "Monospaced";
            default -> fontFamily;
        };
        int fontStyle = Font.PLAIN;
        String styleStr = node.get("font-style", "normal");
        if ("italic".equals(styleStr) || "oblique".equals(styleStr)) {
            fontStyle |= Font.ITALIC;
        }
        String weightStr = node.get("font-weight", "normal");
        if ("bold".equals(weightStr) || "bolder".equals(weightStr)) {
            fontStyle |= Font.BOLD;
        } else if (NUMERIC_PATTERN.matcher(weightStr).matches() && Integer.parseInt(weightStr) >= 550) {
            fontStyle |= Font.BOLD;
        }
        String fontKey = fontFamily + "|" + fontStyle + "|" + (float) surface.fontSize;
        Font font = FONT_CACHE.get(fontKey);
        if (font == null) {
            font = new Font(fontFamily, fontStyle, 1).deriveFont((float) surface.fontSize);
            FONT_CACHE.put(fontKey, font);
        }
        return font;
    }
}