Colors.java

package io.brunoborges.jairosvg.css;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * SVG color parsing. Converts color strings to RGBA tuples (0.0–1.0). Port of
 * CairoSVG colors.py
 */
public final class Colors {

    /** RGBA color as (r, g, b, a) with each component in [0.0, 1.0]. */
    public record RGBA(double r, double g, double b, double a) {
        public static final RGBA TRANSPARENT = new RGBA(0, 0, 0, 0);
        public static final RGBA BLACK = new RGBA(0, 0, 0, 1);
    }

    private static final Pattern RGBA_PATTERN = Pattern.compile("rgba\\((.+?)\\)");
    private static final Pattern RGB_PATTERN = Pattern.compile("rgb\\((.+?)\\)");
    private static final Pattern HSLA_PATTERN = Pattern.compile("hsla\\((.+?)\\)");
    private static final Pattern HSL_PATTERN = Pattern.compile("hsl\\((.+?)\\)");
    private static final Pattern HEX_RRGGBB = Pattern.compile("#[0-9a-f]{6}");
    private static final Pattern HEX_RGB = Pattern.compile("#[0-9a-f]{3}");

    private static final Map<String, RGBA> NAMED_COLORS = loadNamedColors();

    private Colors() {
    }

    private static Map<String, RGBA> loadNamedColors() {
        var props = new Properties();
        try (var in = Colors.class.getResourceAsStream("colors.properties")) {
            if (in == null) {
                throw new ExceptionInInitializerError("colors.properties not found on classpath");
            }
            props.load(in);
        } catch (IOException e) {
            throw new ExceptionInInitializerError(e);
        }
        var map = new HashMap<String, RGBA>(props.size() * 2);
        for (var name : props.stringPropertyNames()) {
            String value = props.getProperty(name);
            if ("transparent".equals(value)) {
                map.put(name, RGBA.TRANSPARENT);
            } else {
                String[] parts = value.split(",");
                double r = Integer.parseInt(parts[0]) / 255.0;
                double g = Integer.parseInt(parts[1]) / 255.0;
                double b = Integer.parseInt(parts[2]) / 255.0;
                double a = parts.length > 3 ? Double.parseDouble(parts[3]) : 1.0;
                map.put(name, new RGBA(r, g, b, a));
            }
        }
        return Map.copyOf(map);
    }

    /** Parse a color string into RGBA. */
    public static RGBA color(String string, double opacity) {
        if (string == null || string.isBlank()) {
            return RGBA.TRANSPARENT;
        }

        string = string.strip().toLowerCase();

        RGBA named = NAMED_COLORS.get(string);
        if (named != null) {
            return (opacity >= 1.0 && named.a() >= 1.0)
                    ? named
                    : new RGBA(named.r(), named.g(), named.b(), named.a() * opacity);
        }

        Matcher m = RGBA_PATTERN.matcher(string);
        if (m.find()) {
            String[] parts = m.group(1).strip().split(",");
            if (parts.length == 4) {
                double r = parseColorComponent(parts[0]);
                double g = parseColorComponent(parts[1]);
                double b = parseColorComponent(parts[2]);
                double a = Double.parseDouble(parts[3].strip());
                return new RGBA(r, g, b, a * opacity);
            }
        }

        m = RGB_PATTERN.matcher(string);
        if (m.find()) {
            String[] parts = m.group(1).strip().split(",");
            if (parts.length == 3) {
                double r = parseColorComponent(parts[0]);
                double g = parseColorComponent(parts[1]);
                double b = parseColorComponent(parts[2]);
                return new RGBA(r, g, b, opacity);
            }
        }

        m = HSLA_PATTERN.matcher(string);
        if (m.find()) {
            String[] parts = m.group(1).strip().split(",");
            if (parts.length == 4) {
                double[] rgb = hslToRgb(parts[0], parts[1], parts[2]);
                double a = Double.parseDouble(parts[3].strip());
                return new RGBA(rgb[0], rgb[1], rgb[2], a * opacity);
            }
        }

        m = HSL_PATTERN.matcher(string);
        if (m.find()) {
            String[] parts = m.group(1).strip().split(",");
            if (parts.length == 3) {
                double[] rgb = hslToRgb(parts[0], parts[1], parts[2]);
                return new RGBA(rgb[0], rgb[1], rgb[2], opacity);
            }
        }

        m = HEX_RRGGBB.matcher(string);
        if (m.find()) {
            double r = Integer.parseInt(string, 1, 3, 16) / 255.0;
            double g = Integer.parseInt(string, 3, 5, 16) / 255.0;
            double b = Integer.parseInt(string, 5, 7, 16) / 255.0;
            return new RGBA(r, g, b, opacity);
        }

        m = HEX_RGB.matcher(string);
        if (m.find()) {
            double r = Character.digit(string.charAt(1), 16) / 15.0;
            double g = Character.digit(string.charAt(2), 16) / 15.0;
            double b = Character.digit(string.charAt(3), 16) / 15.0;
            return new RGBA(r, g, b, opacity);
        }

        return RGBA.BLACK;
    }

    /** Parse with default opacity of 1. */
    public static RGBA color(String string) {
        return color(string, 1.0);
    }

    /** Negate (complement) a color. */
    public static RGBA negateColor(RGBA c) {
        return new RGBA(1 - c.r(), 1 - c.g(), 1 - c.b(), c.a());
    }

    private static double[] hslToRgb(String hPart, String sPart, String lPart) {
        double h = (((Double.parseDouble(hPart.strip()) % 360) + 360) % 360) / 360.0;
        sPart = sPart.strip();
        lPart = lPart.strip();
        if (!sPart.endsWith("%") || !lPart.endsWith("%")) {
            throw new IllegalArgumentException("Saturation and lightness in hsl() must be percentages.");
        }
        double s = Double.parseDouble(sPart.substring(0, sPart.length() - 1)) / 100.0;
        double l = Double.parseDouble(lPart.substring(0, lPart.length() - 1)) / 100.0;
        if (s == 0) {
            return new double[]{l, l, l};
        }
        double q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        double p = 2 * l - q;
        return new double[]{hueToRgb(p, q, h + 1.0 / 3), hueToRgb(p, q, h), hueToRgb(p, q, h - 1.0 / 3)};
    }

    private static double hueToRgb(double p, double q, double t) {
        if (t < 0)
            t += 1;
        if (t > 1)
            t -= 1;
        if (t < 1.0 / 6)
            return p + (q - p) * 6 * t;
        if (t < 1.0 / 2)
            return q;
        if (t < 2.0 / 3)
            return p + (q - p) * (2.0 / 3 - t) * 6;
        return p;
    }

    private static double parseColorComponent(String part) {
        part = part.strip();
        if (part.endsWith("%")) {
            return Double.parseDouble(part.substring(0, part.length() - 1)) / 100.0;
        }
        return Double.parseDouble(part) / 255.0;
    }
}