Helpers.java

package io.brunoborges.jairosvg.util;

import java.awt.geom.AffineTransform;
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.
 */
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 static final int CALC_PREFIX_LENGTH = 5; // length of "calc("

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

    // ── Geometry ───────────────────────────────────────────────────────────

    // ── Units & sizing ──────────────────────────────────────────────────────

    /** 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();
        if (value.startsWith("url")) {
            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]};
    }

    // ── String normalization ─────────────────────────────────────────────

    /** 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);
    }

    // ── Transforms ─────────────────────────────────────────────────────────

    /**
     * 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. */
    // Reusable AffineTransform for matrix() transform function — avoids per-call
    // allocation
    private static final ThreadLocal<AffineTransform> TEMP_AT = ThreadLocal.withInitial(AffineTransform::new);

    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 = TEMP_AT.get();
                        m.setTransform(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" -> matrix.shear(Math.tan(Math.toRadians(values[0])), 0);
                case "skewY" -> matrix.shear(0, Math.tan(Math.toRadians(values[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, with Node-level
     * caching.
     */
    public static void transform(Surface surface, Node node, String transformString, String transformOrigin) {
        if (transformString == null || transformString.isEmpty())
            return;

        AffineTransform matrix;
        if (transformOrigin == null && node.cachedTransformStr != null
                && node.cachedTransformStr.equals(transformString)) {
            matrix = node.cachedTransform;
        } else {
            matrix = parseTransform(surface, transformString);
            if (transformOrigin == null) {
                node.cachedTransform = matrix;
                node.cachedTransformStr = transformString;
            }
        }

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

        surface.context.transform(matrix);
    }

    // ── Clip helpers ────────────────────────────────────────────────────

    /** 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];
    }

    // ── Units and size parsing ──────────────────────────────────────────

    /**
     * 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);
        }

        // Handle CSS calc() expressions
        String stripped = string.strip();
        if (stripped.length() > CALC_PREFIX_LENGTH
                && stripped.substring(0, CALC_PREFIX_LENGTH).equalsIgnoreCase("calc(")) {
            return evalCalc(surface, stripped, reference);
        }

        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 if (string.endsWith("pt")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "pt";
        } 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("pt")) {
            numPart = string.substring(0, string.length() - 2);
            unit = "pt";
        } 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");
        };
    }

    /**
     * Evaluate a CSS calc() expression, e.g. {@code calc(100% - 20px)}. Supports +,
     * -, *, / and nested parentheses.
     */
    private static double evalCalc(Surface surface, String expr, String reference) {
        // expr starts with "calc(" (case-insensitive); find matching closing paren
        int innerStart = CALC_PREFIX_LENGTH; // skip "calc("
        int depth = 1, i = innerStart;
        while (i < expr.length() && depth > 0) {
            char c = expr.charAt(i);
            if (c == '(')
                depth++;
            else if (c == ')')
                depth--;
            i++;
        }
        String inner = expr.substring(innerStart, i - 1).strip();
        int[] pos = {0};
        return calcAddSub(surface, inner, reference, pos);
    }

    /** Parse addition/subtraction in a calc expression (lowest precedence). */
    private static double calcAddSub(Surface surface, String expr, String reference, int[] pos) {
        double left = calcMulDiv(surface, expr, reference, pos);
        while (pos[0] < expr.length()) {
            skipCalcWhitespace(expr, pos);
            if (pos[0] >= expr.length())
                break;
            char op = expr.charAt(pos[0]);
            if (op != '+' && op != '-')
                break;
            pos[0]++;
            double right = calcMulDiv(surface, expr, reference, pos);
            if (op == '+')
                left += right;
            else
                left -= right;
        }
        return left;
    }

    /** Parse multiplication/division in a calc expression (higher precedence). */
    private static double calcMulDiv(Surface surface, String expr, String reference, int[] pos) {
        double left = calcFactor(surface, expr, reference, pos);
        while (pos[0] < expr.length()) {
            skipCalcWhitespace(expr, pos);
            if (pos[0] >= expr.length())
                break;
            char op = expr.charAt(pos[0]);
            if (op != '*' && op != '/')
                break;
            pos[0]++;
            double right = calcFactor(surface, expr, reference, pos);
            if (op == '*')
                left *= right;
            else
                left /= right;
        }
        return left;
    }

    /**
     * Parse a calc factor: a parenthesised sub-expression, a nested calc(), or a
     * CSS length/percentage value.
     */
    private static double calcFactor(Surface surface, String expr, String reference, int[] pos) {
        skipCalcWhitespace(expr, pos);
        if (pos[0] >= expr.length())
            return 0;

        char first = expr.charAt(pos[0]);

        // Parenthesised sub-expression or nested calc(...)
        if (first == '(') {
            pos[0]++; // skip '('
            double val = calcAddSub(surface, expr, reference, pos);
            skipCalcWhitespace(expr, pos);
            if (pos[0] < expr.length() && expr.charAt(pos[0]) == ')')
                pos[0]++;
            return val;
        }

        // Nested calc() keyword
        if (pos[0] + CALC_PREFIX_LENGTH <= expr.length()
                && expr.substring(pos[0], pos[0] + CALC_PREFIX_LENGTH).equalsIgnoreCase("calc(")) {
            int nestedStart = pos[0];
            int depth = 0;
            while (pos[0] < expr.length()) {
                char c = expr.charAt(pos[0]);
                if (c == '(')
                    depth++;
                else if (c == ')') {
                    depth--;
                    if (depth == 0) {
                        pos[0]++;
                        break;
                    }
                }
                pos[0]++;
            }
            return evalCalc(surface, expr.substring(nestedStart, pos[0]), reference);
        }

        // CSS value token: optional sign, digits/dot, optional exponent, optional unit
        int start = pos[0];

        // Optional sign
        if (pos[0] < expr.length() && (first == '-' || first == '+')) {
            pos[0]++;
        }

        // Numeric part (digits and decimal point)
        while (pos[0] < expr.length() && (Character.isDigit(expr.charAt(pos[0])) || expr.charAt(pos[0]) == '.')) {
            pos[0]++;
        }

        // Optional exponent: e/E followed by optional sign then digits
        if (pos[0] < expr.length() && (expr.charAt(pos[0]) == 'e' || expr.charAt(pos[0]) == 'E')) {
            int savedPos = pos[0];
            pos[0]++;
            if (pos[0] < expr.length() && (expr.charAt(pos[0]) == '+' || expr.charAt(pos[0]) == '-')) {
                pos[0]++;
            }
            if (pos[0] < expr.length() && Character.isDigit(expr.charAt(pos[0]))) {
                while (pos[0] < expr.length() && Character.isDigit(expr.charAt(pos[0])))
                    pos[0]++;
            } else {
                pos[0] = savedPos; // not a valid exponent — 'e' starts the unit suffix
            }
        }

        // Optional unit suffix (letters, e.g. px, em, rem, pt, cm, mm, in, pc)
        while (pos[0] < expr.length() && Character.isLetter(expr.charAt(pos[0]))) {
            pos[0]++;
        }
        // Percentage sign
        if (pos[0] < expr.length() && expr.charAt(pos[0]) == '%') {
            pos[0]++;
        }

        String token = expr.substring(start, pos[0]).strip();
        if (token.isEmpty())
            return 0;
        return size(surface, token, reference);
    }

    /** Advance past whitespace characters in a calc expression string. */
    private static void skipCalcWhitespace(String s, int[] pos) {
        while (pos[0] < s.length() && Character.isWhitespace(s.charAt(pos[0]))) {
            pos[0]++;
        }
    }

    // ── Numeric parsing utilities ────────────────────────────────────────

    /**
     * Parse a double from a string, returning {@code def} on null or parse failure.
     */
    /**
     * Parse a double from a string, returning the given default on
     * null/empty/error.
     */
    public static double parseDoubleOr(String s, double defaultValue) {
        if (s == null || s.isEmpty())
            return defaultValue;
        try {
            return Double.parseDouble(s);
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }

    /** Parse a double from a string, returning 0 on null or parse failure. */
    public static double parseDouble(String s) {
        return parseDoubleOr(s, 0);
    }

    /**
     * Parse a float that may be expressed as a percentage (e.g. "50%"). Returns 0
     * on null/empty/parse-error.
     */
    public static float parsePercent(String s) {
        if (s == null || s.isEmpty())
            return 0;
        try {
            s = s.strip();
            if (s.endsWith("%")) {
                return Float.parseFloat(s.substring(0, s.length() - 1)) / 100f;
            }
            return Float.parseFloat(s);
        } catch (NumberFormatException e) {
            return 0;
        }
    }
}