Helpers.java

package io.brunoborges.jairosvg.util;

import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import io.brunoborges.jairosvg.dom.Node;
import io.brunoborges.jairosvg.surface.Surface;

/**
 * Surface helpers: size parsing, transforms, normalize, point, etc. Port of
 * CairoSVG helpers.py
 */
public final class Helpers {

    public static final Map<String, Double> UNITS = Map.of("mm", 1.0 / 25.4, "cm", 1.0 / 2.54, "in", 1.0, "pt",
            1.0 / 72.0, "pc", 1.0 / 6.0);

    public static final Pattern PAINT_URL = Pattern.compile("(url\\(.+\\))\\s*(.*)");
    public static final String PATH_LETTERS = "achlmqstvzACHLMQSTVZ";
    private static final Pattern RECT_PATTERN = Pattern.compile("rect\\(\\s*(.+?)\\s*\\)");
    private static final Pattern NEGATIVE_SIGN = Pattern.compile("(?<!e)-");
    private static final Pattern WHITESPACE_COMMA = Pattern.compile("[\\s,]+");
    private static final Pattern DECIMAL_SPLIT = Pattern.compile("(\\.[0-9-]+)(?=\\.)");
    private static final Pattern POINT_PATTERN = Pattern.compile("^(\\S+?)\\s+(\\S+?)(?:\\s+|$)");
    private static final Pattern TRANSFORM_PATTERN = Pattern.compile("(\\w+)\\s*\\(\\s*(.*?)\\s*\\)");
    private static final Pattern WHITESPACE = Pattern.compile("\\s+");
    private static final String[] EMPTY_PAINT = {null, null};
    private static final double[] DEFAULT_RATIO = {1, 1, 0, 0};

    private Helpers() {
    }

    public static class PointError extends RuntimeException {
        public PointError() {
            super();
        }
        public PointError(String msg) {
            super(msg);
        }
    }

    /** Distance between two points. */
    public static double distance(double x1, double y1, double x2, double y2) {
        return Math.hypot(x2 - x1, y2 - y1);
    }

    /** Extract URI and color from a paint value. Returns [source, color]. */
    public static String[] paint(String value) {
        if (value == null || value.isBlank()) {
            return EMPTY_PAINT;
        }
        value = value.strip();
        Matcher m = PAINT_URL.matcher(value);
        if (m.find()) {
            String source = UrlHelper.parseUrl(m.group(1)).fragment();
            String color = m.group(2).isEmpty() ? null : m.group(2);
            return new String[]{source, color};
        }
        return new String[]{null, value.isEmpty() ? null : value};
    }

    /** Return (width, height, viewbox) of a node. */
    public static double[] nodeFormat(Surface surface, Node node, boolean reference) {
        String refSize = reference ? "xy" : null;
        double width = size(surface, node.get("width", "100%"), refSize != null ? "x" : null);
        double height = size(surface, node.get("height", "100%"), refSize != null ? "y" : null);
        String viewboxStr = node.get("viewBox");
        double[] viewbox = null;
        if (viewboxStr != null && !viewboxStr.isEmpty()) {
            viewboxStr = WHITESPACE_COMMA.matcher(viewboxStr).replaceAll(" ").strip();
            String[] parts = viewboxStr.split(" ");
            if (parts.length == 4) {
                viewbox = new double[]{Double.parseDouble(parts[0]), Double.parseDouble(parts[1]),
                        Double.parseDouble(parts[2]), Double.parseDouble(parts[3])};
                if (width == 0)
                    width = viewbox[2];
                if (height == 0)
                    height = viewbox[3];
            }
        }
        return new double[]{width, height, viewbox != null ? viewbox[0] : Double.NaN,
                viewbox != null ? viewbox[1] : Double.NaN, viewbox != null ? viewbox[2] : Double.NaN,
                viewbox != null ? viewbox[3] : Double.NaN};
    }

    public static double[] nodeFormat(Surface surface, Node node) {
        return nodeFormat(surface, node, true);
    }

    /** Check if viewbox is present (not NaN). */
    public static boolean hasViewbox(double[] nodeFormat) {
        return !Double.isNaN(nodeFormat[2]);
    }

    /** Get viewbox part from nodeFormat result. Returns null if no viewbox. */
    public static double[] getViewbox(double[] nodeFormat) {
        if (!hasViewbox(nodeFormat))
            return null;
        return new double[]{nodeFormat[2], nodeFormat[3], nodeFormat[4], nodeFormat[5]};
    }

    /** Normalize a string corresponding to an array of various values. */
    public static String normalize(String string) {
        if (string == null || string.isEmpty())
            return "";
        string = string.replace('E', 'e');
        string = NEGATIVE_SIGN.matcher(string).replaceAll(" -");
        string = WHITESPACE_COMMA.matcher(string).replaceAll(" ");
        string = DECIMAL_SPLIT.matcher(string).replaceAll("$1 ");
        return string.strip();
    }

    /** Return (x, y, trailing_text) from string. */
    public static double[] point(Surface surface, String string) {
        string = string.strip();
        Matcher m = POINT_PATTERN.matcher(string);
        if (m.find()) {
            double x = size(surface, m.group(1), "x");
            double y = size(surface, m.group(2), "y");
            return new double[]{x, y};
        }
        throw new PointError("Cannot parse point from: " + string);
    }

    /** Return (x, y, remaining_string). */
    public static ParsedPoint pointWithRemainder(Surface surface, String string) {
        string = string.strip();
        Matcher m = POINT_PATTERN.matcher(string);
        if (m.find()) {
            double x = size(surface, m.group(1), "x");
            double y = size(surface, m.group(2), "y");
            String remainder = string.substring(m.end()).strip();
            return new ParsedPoint(x, y, remainder);
        }
        throw new PointError("Cannot parse point from: " + string);
    }

    /** Return angle between x axis and point knowing given center. */
    public static double pointAngle(double cx, double cy, double px, double py) {
        return Math.atan2(py - cy, px - cx);
    }

    /**
     * Manage the ratio preservation. Returns [scaleX, scaleY, translateX,
     * translateY].
     */
    public static double[] preserveRatio(Surface surface, Node node, double width, double height) {
        double viewboxWidth, viewboxHeight;

        if ("marker".equals(node.tag)) {
            if (width == 0)
                width = size(surface, node.get("markerWidth", "3"), "x");
            if (height == 0)
                height = size(surface, node.get("markerHeight", "3"), "y");
            double[] nf = nodeFormat(surface, node);
            double[] vb = getViewbox(nf);
            if (vb == null)
                return DEFAULT_RATIO;
            viewboxWidth = vb[2];
            viewboxHeight = vb[3];
        } else if ("svg".equals(node.tag) || "image".equals(node.tag) || "g".equals(node.tag)) {
            double[] nf = nodeFormat(surface, node);
            if (width == 0)
                width = nf[0];
            if (height == 0)
                height = nf[1];
            viewboxWidth = node.imageWidth;
            viewboxHeight = node.imageHeight;
        } else {
            throw new IllegalArgumentException(
                    "Root node is " + node.tag + ". Should be one of marker, svg, image, or g.");
        }

        double translateX = 0, translateY = 0;
        double scaleX = viewboxWidth > 0 ? width / viewboxWidth : 1;
        double scaleY = viewboxHeight > 0 ? height / viewboxHeight : 1;

        String par = node.get("preserveAspectRatio", "xMidYMid");
        String[] parts = WHITESPACE.split(par);
        String align = parts[0];

        String xPosition, yPosition;
        if ("none".equals(align)) {
            xPosition = "min";
            yPosition = "min";
        } else {
            String meetOrSlice = parts.length > 1 ? parts[1] : null;
            double scaleValue;
            if ("slice".equals(meetOrSlice)) {
                scaleValue = Math.max(scaleX, scaleY);
            } else {
                scaleValue = Math.min(scaleX, scaleY);
            }
            scaleX = scaleY = scaleValue;
            xPosition = align.substring(1, 4).toLowerCase();
            yPosition = align.substring(5).toLowerCase();
        }

        if ("marker".equals(node.tag)) {
            translateX = -size(surface, node.get("refX", "0"), "x");
            translateY = -size(surface, node.get("refY", "0"), "y");
        } else {
            if ("mid".equals(xPosition)) {
                translateX = (width / scaleX - viewboxWidth) / 2;
            } else if ("max".equals(xPosition)) {
                translateX = width / scaleX - viewboxWidth;
            }
            if ("mid".equals(yPosition)) {
                translateY = (height / scaleY - viewboxHeight) / 2;
            } else if ("max".equals(yPosition)) {
                translateY = height / scaleY - viewboxHeight;
            }
        }

        return new double[]{scaleX, scaleY, translateX, translateY};
    }

    public static double[] preserveRatio(Surface surface, Node node) {
        return preserveRatio(surface, node, 0, 0);
    }

    /** Get clip (x, y, width, height) of marker box. */
    public static double[] clipMarkerBox(Surface surface, Node node, double scaleX, double scaleY) {
        double mw = size(surface, node.get("markerWidth", "3"), "x");
        double mh = size(surface, node.get("markerHeight", "3"), "y");
        double[] nf = nodeFormat(surface, node);
        double[] vb = getViewbox(nf);
        if (vb == null)
            return new double[]{0, 0, mw, mh};
        double vbW = vb[2], vbH = vb[3];

        String align = WHITESPACE.split(node.get("preserveAspectRatio", "xMidYMid"))[0];
        String xPos = "none".equals(align) ? "min" : align.substring(1, 4).toLowerCase();
        String yPos = "none".equals(align) ? "min" : align.substring(5).toLowerCase();

        double clipX = vb[0];
        if ("mid".equals(xPos))
            clipX += (vbW - mw / scaleX) / 2.0;
        else if ("max".equals(xPos))
            clipX += vbW - mw / scaleX;

        double clipY = vb[1];
        if ("mid".equals(yPos))
            clipY += (vbH - mh / scaleY) / 2.0;
        else if ("max".equals(yPos))
            clipY += vbH - mh / scaleY;

        return new double[]{clipX, clipY, mw / scaleX, mh / scaleY};
    }

    /** Return quadratic points for cubic curve approximation. */
    public static double[] quadraticPoints(double x1, double y1, double x2, double y2, double x3, double y3) {
        double xq1 = x2 * 2.0 / 3 + x1 / 3.0;
        double yq1 = y2 * 2.0 / 3 + y1 / 3.0;
        double xq2 = x2 * 2.0 / 3 + x3 / 3.0;
        double yq2 = y2 * 2.0 / 3 + y3 / 3.0;
        return new double[]{xq1, yq1, xq2, yq2, x3, y3};
    }

    /** Rotate a point by angle around origin. */
    public static double[] rotate(double x, double y, double angle) {
        return new double[]{x * Math.cos(angle) - y * Math.sin(angle), y * Math.cos(angle) + x * Math.sin(angle)};
    }

    /** Parse an SVG transform string into an AffineTransform. */
    public static AffineTransform parseTransform(Surface surface, String transformString) {
        if (transformString == null || transformString.isEmpty())
            return new AffineTransform();

        String normalized = normalize(transformString);
        Matcher tm = TRANSFORM_PATTERN.matcher(normalized);

        AffineTransform matrix = new AffineTransform();

        while (tm.find()) {
            String type = tm.group(1);
            String[] valStrs = WHITESPACE.split(tm.group(2).strip());
            double[] values = new double[valStrs.length];
            for (int i = 0; i < valStrs.length; i++) {
                values[i] = size(surface, valStrs[i]);
            }

            switch (type) {
                case "matrix" -> {
                    if (values.length >= 6) {
                        AffineTransform m = new AffineTransform(values[0], values[1], values[2], values[3], values[4],
                                values[5]);
                        matrix.concatenate(m);
                    }
                }
                case "rotate" -> {
                    double angle = Math.toRadians(values[0]);
                    double cx = values.length > 1 ? values[1] : 0;
                    double cy = values.length > 2 ? values[2] : 0;
                    matrix.translate(cx, cy);
                    matrix.rotate(angle);
                    matrix.translate(-cx, -cy);
                }
                case "skewX" -> {
                    double tangent = Math.tan(Math.toRadians(values[0]));
                    matrix.concatenate(new AffineTransform(1, 0, tangent, 1, 0, 0));
                }
                case "skewY" -> {
                    double tangent = Math.tan(Math.toRadians(values[0]));
                    matrix.concatenate(new AffineTransform(1, tangent, 0, 1, 0, 0));
                }
                case "translate" -> {
                    double tx = values[0];
                    double ty = values.length > 1 ? values[1] : 0;
                    matrix.translate(tx, ty);
                }
                case "scale" -> {
                    double sx = values[0];
                    double sy = values.length > 1 ? values[1] : sx;
                    matrix.scale(sx, sy);
                }
            }
        }
        return matrix;
    }

    /** Apply SVG transform string to the surface Graphics2D. */
    public static void transform(Surface surface, String transformString, AffineTransform gradient,
            String transformOrigin) {
        if (transformString == null || transformString.isEmpty())
            return;

        AffineTransform matrix = parseTransform(surface, transformString);

        // Handle transform-origin
        if (transformOrigin != null && !transformOrigin.isEmpty()) {
            String[] origin = WHITESPACE.split(transformOrigin.strip());
            double originX = parseOriginComponent(surface, origin[0], true);
            double originY = origin.length > 1
                    ? parseOriginComponent(surface, origin[1], false)
                    : surface.contextHeight / 2;
            AffineTransform withOrigin = new AffineTransform();
            withOrigin.translate(originX, originY);
            withOrigin.concatenate(matrix);
            withOrigin.translate(-originX, -originY);
            matrix = withOrigin;
        }

        if (gradient != null) {
            try {
                AffineTransform inv = matrix.createInverse();
                gradient.concatenate(inv);
            } catch (NoninvertibleTransformException e) {
                // Non-invertible, skip
            }
        } else {
            surface.context.transform(matrix);
        }
    }

    public static void transform(Surface surface, String transformString) {
        transform(surface, transformString, null, null);
    }

    /** Parse clip rect values. */
    public static String[] clipRect(String string) {
        if (string == null || string.isEmpty())
            return new String[0];
        Matcher m = RECT_PATTERN.matcher(normalize(string));
        if (m.find()) {
            return WHITESPACE.split(m.group(1));
        }
        return new String[0];
    }

    /** Get original rotation values from a node. */
    public static List<Double> rotations(Node node) {
        String rotate = node.get("rotate");
        if (rotate != null && !rotate.isEmpty()) {
            List<Double> result = new ArrayList<>();
            for (String s : WHITESPACE.split(normalize(rotate).strip())) {
                result.add(Double.parseDouble(s));
            }
            return result;
        }
        return new ArrayList<>();
    }

    /** Pop rotation values already used. */
    public static void popRotation(Node node, List<Double> originalRotate, List<Double> rotate) {
        String text = node.text;
        if (text == null)
            text = "";
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < text.length(); i++) {
            if (i > 0)
                sb.append(' ');
            double r = !rotate.isEmpty() ? rotate.remove(0) : originalRotate.get(originalRotate.size() - 1);
            sb.append(r);
        }
        node.set("rotate", sb.toString());
    }

    /**
     * Check if a string contains only plain numeric characters (digits, dot, minus,
     * plus, e/E).
     */
    private static boolean isPlainNumber(String s) {
        for (int i = 0, len = s.length(); i < len; i++) {
            char c = s.charAt(i);
            if (!((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')) {
                return false;
            }
        }
        return true;
    }

    /**
     * Replace a string with units by a float value. Reference: 'x' = viewport
     * width, 'y' = viewport height, 'xy' = diagonal
     */
    public static double size(Surface surface, String string, String reference) {
        if (string == null || string.isEmpty())
            return 0;

        if (isPlainNumber(string)) {
            return Double.parseDouble(string);
        }

        if (surface == null)
            return 0;

        // Quick unit suffix check — avoids normalize() for simple "100px", "50%", etc.
        String numPart = null;
        String unit = null;
        if (string.endsWith("%")) {
            numPart = string.substring(0, string.length() - 1);
            unit = "%";
        } else if (string.endsWith("px")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "px";
        } else if (string.endsWith("em")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "em";
        } else if (string.endsWith("ex")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "ex";
        } else if (string.endsWith("ch")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "ch";
        } else {
            for (var entry : UNITS.entrySet()) {
                if (string.endsWith(entry.getKey())) {
                    numPart = string.substring(0, string.length() - entry.getKey().length());
                    unit = entry.getKey();
                    break;
                }
            }
        }

        if (numPart != null && isPlainNumber(numPart)) {
            return sizeWithUnit(surface, numPart, unit, reference);
        }

        // Fallback: normalize for complex strings (e.g. containing whitespace or
        // multiple values)
        String normalized = normalize(string);
        int spaceIdx = normalized.indexOf(' ');
        string = spaceIdx > 0 ? normalized.substring(0, spaceIdx) : normalized;

        if (string.endsWith("%")) {
            numPart = string.substring(0, string.length() - 1);
            unit = "%";
        } else if (string.endsWith("em")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "em";
        } else if (string.endsWith("ex")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "ex";
        } else if (string.endsWith("ch")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "ch";
        } else if (string.endsWith("px")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "px";
        } else {
            for (var entry : UNITS.entrySet()) {
                if (string.endsWith(entry.getKey())) {
                    numPart = string.substring(0, string.length() - entry.getKey().length());
                    unit = entry.getKey();
                    break;
                }
            }
        }

        if (numPart != null) {
            return sizeWithUnit(surface, numPart, unit, reference);
        }

        return 0;
    }

    /** Compute a sized value given a numeric string and a unit. */
    private static double sizeWithUnit(Surface surface, String numPart, String unit, String reference) {
        double value = Double.parseDouble(numPart);
        return switch (unit) {
            case "%" -> {
                double ref;
                if ("x".equals(reference)) {
                    ref = surface.contextWidth;
                } else if ("y".equals(reference)) {
                    ref = surface.contextHeight;
                } else {
                    ref = Math.hypot(surface.contextWidth, surface.contextHeight) / Math.sqrt(2);
                }
                yield value * ref / 100;
            }
            case "em" -> surface.fontSize * value;
            case "ex", "ch" -> surface.fontSize * value / 2;
            case "px" -> value;
            default -> {
                Double factor = UNITS.get(unit);
                yield factor != null ? value * surface.dpi * factor : 0;
            }
        };
    }

    /** Size with no reference. */
    public static double size(Surface surface, String string) {
        return size(surface, string, "xy");
    }

    /** Parse font shorthand property. */
    public static Map<String, String> parseFont(String value) {
        var result = new java.util.HashMap<>(Map.of("font-family", "", "font-size", "", "font-style", "normal",
                "font-variant", "normal", "font-weight", "normal", "line-height", "normal"));

        var fontStyles = List.of("italic", "oblique");
        var fontVariants = List.of("small-caps");
        var fontWeights = List.of("bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600", "700", "800",
                "900");

        for (String element : WHITESPACE.split(value)) {
            if ("normal".equals(element))
                continue;
            if (!result.get("font-family").isEmpty()) {
                result.put("font-family", result.get("font-family") + " " + element);
            } else if (fontStyles.contains(element)) {
                result.put("font-style", element);
            } else if (fontVariants.contains(element)) {
                result.put("font-variant", element);
            } else if (fontWeights.contains(element)) {
                result.put("font-weight", element);
            } else {
                if (result.get("font-size").isEmpty()) {
                    int slashIdx = element.indexOf('/');
                    if (slashIdx >= 0) {
                        result.put("font-size", element.substring(0, slashIdx));
                        result.put("line-height", element.substring(slashIdx + 1));
                    } else {
                        result.put("font-size", element);
                    }
                } else {
                    result.put("font-family", element);
                }
            }
        }
        return result;
    }

    private static double parseOriginComponent(Surface surface, String value, boolean isX) {
        return switch (value) {
            case "center" -> isX ? surface.contextWidth / 2 : surface.contextHeight / 2;
            case "left" -> 0;
            case "right" -> surface.contextWidth;
            case "top" -> 0;
            case "bottom" -> surface.contextHeight;
            default -> size(surface, value, isX ? "x" : "y");
        };
    }
}