Skip to content

Color Picker & Converter — Java source

Pick a color and convert between HEX, RGB, HSL, HSV, and CMYK with a live preview. Edit any format and copy the rest - runs entirely in your browser.

This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

/*
 * color-picker — color-space conversions, WCAG contrast, and named-color lookup.
 *
 * Language: Java 17 (java.* standard library only).
 *
 * Source:   CosmoDev polyglot showcase port of the color-picker tool, ported
 *           from src/lib/colorConvert.ts. Functionally equivalent: identical
 *           outputs for identical inputs, including clamping, NaN/Infinity
 *           handling, and null on invalid hex.
 *
 * RGB is the canonical hub — every other space converts through it — and
 * normalizeColor() re-derives the hex from its own clamped RGB so the five
 * display formats can never disagree.
 *
 * License:  display source — part of CosmoDev's polyglot tool pages.
 */

import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/** A color resolved into every supported space, all mutually consistent. */
record ColorBundle(
        String hex,        // "#rrggbb" (lowercase) — the canonical handle
        double[] rgb,      // 0–255 each
        double[] hsl,      // h: 0–360, s/l: 0–100
        double[] hsv,      // h: 0–360, s/v: 0–100
        double[] cmyk      // 0–100 each
) {}

/** A named CSS color with its hex. */
record NamedColor(String name, String hex) {}

final class ColorPicker {

    private ColorPicker() {}

    // --- Internal clamp helpers ------------------------------------------------
    // Every public method is total: invalid hex → null; out-of-range numbers are
    // clamped into their valid interval. Math.min/max propagate NaN, so NaN is
    // handled explicitly before they ever see it.

    private static double clamp(double n, double lo, double hi) {
        if (Double.isNaN(n)) return lo;
        if (n == Double.POSITIVE_INFINITY) return hi;
        if (n == Double.NEGATIVE_INFINITY) return lo;
        return Math.min(hi, Math.max(lo, n));
    }

    private static double clamp01(double n) {
        return clamp(n, 0.0, 1.0);
    }

    /**
     * Round half away from zero — mirrors JS {@code Math.round} for the
     * non-negative values this class rounds. The explicit {@code floor(n + 0.5)}
     * form documents the parity with the TypeScript source.
     */
    private static double roundHalfUp(double n) {
        return Math.floor(n + 0.5);
    }

    /**
     * Normalize any hue (negative, >360, or NaN) into [0, 360), matching
     * JS {@code ((Number(h) || 0) % 360 + 360) % 360}. Java's % takes the
     * dividend's sign, like JS.
     */
    private static double mod360(double h) {
        if (Double.isNaN(h)) h = 0.0;
        return ((h % 360.0) + 360.0) % 360.0;
    }

    private static boolean isHexDigits(String s) {
        if (s.isEmpty()) return false;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
            if (!ok) return false;
        }
        return true;
    }

    /** Parse exactly two validated ASCII hex digits into 0–255. */
    private static double parseHexByte(String two) {
        return Integer.parseInt(two, 16);
    }

    // --- HEX ↔ RGB ---------------------------------------------------------------

    /**
     * Parse {@code #rgb} / {@code #rrggbb} (case-insensitive, {@code #} optional)
     * into {@code {r,g,b}}, or null for anything that is not a 3- or 6-digit
     * hex color.
     */
    public static double[] hexToRgb(String value) {
        String h = value.strip();
        if (h.startsWith("#")) h = h.substring(1);          // TS strips exactly one leading '#'.

        if (h.length() == 3 && isHexDigits(h)) {            // expand shorthand #rgb → #rrggbb
            h = "" + h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
        }
        if (h.length() != 6 || !isHexDigits(h)) return null;

        return new double[] {
            parseHexByte(h.substring(0, 2)),
            parseHexByte(h.substring(2, 4)),
            parseHexByte(h.substring(4, 6)),
        };
    }

    /** {r,g,b} (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded). */
    public static String rgbToHex(double r, double g, double b) {
        return String.format(Locale.ROOT, "#%02x%02x%02x",
                (int) roundHalfUp(clamp(r, 0.0, 255.0)),
                (int) roundHalfUp(clamp(g, 0.0, 255.0)),
                (int) roundHalfUp(clamp(b, 0.0, 255.0)));
    }

    // --- RGB ↔ HSL ---------------------------------------------------------------

    /** {r,g,b} (0–255) → {h,s,l} with h: 0–360, s/l: 0–100. */
    public static double[] rgbToHsl(double r, double g, double b) {
        double rn = clamp01(r / 255.0), gn = clamp01(g / 255.0), bn = clamp01(b / 255.0);
        double mx = Math.max(rn, Math.max(gn, bn));
        double mn = Math.min(rn, Math.min(gn, bn));
        double d = mx - mn;
        double l = (mx + mn) / 2.0;
        double h = 0.0, s = 0.0;
        if (d != 0.0) {
            // Saturation formula branches on which half of the lightness axis we sit on.
            s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn);
            if (mx == rn)      h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0);
            else if (mx == gn) h = (bn - rn) / d + 2.0;
            else               h = (rn - gn) / d + 4.0;
            h *= 60.0;
        }
        return new double[] { roundHalfUp(h), roundHalfUp(s * 100.0), roundHalfUp(l * 100.0) };
    }

    /** {h,s,l} (h: 0–360, s/l: 0–100) → {r,g,b} (0–255). */
    public static double[] hslToRgb(double h, double s, double l) {
        double hn = mod360(h);
        double sn = clamp01(s / 100.0);
        double ln = clamp01(l / 100.0);
        double c = (1.0 - Math.abs(2.0 * ln - 1.0)) * sn;
        double x = c * (1.0 - Math.abs(hn / 60.0 % 2.0 - 1.0));
        double m = ln - c / 2.0;
        double rr, gg, bb;
        if (hn < 60.0) {
            rr = c; gg = x; bb = 0.0;
        } else if (hn < 120.0) {
            rr = x; gg = c; bb = 0.0;
        } else if (hn < 180.0) {
            rr = 0.0; gg = c; bb = x;
        } else if (hn < 240.0) {
            rr = 0.0; gg = x; bb = c;
        } else if (hn < 300.0) {
            rr = x; gg = 0.0; bb = c;
        } else {
            rr = c; gg = 0.0; bb = x;
        }
        return new double[] { (rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0 };
    }

    /** "#hex" → {h,s,l}, or null when the hex is invalid. */
    public static double[] hexToHsl(String value) {
        double[] rgb = hexToRgb(value);
        return rgb == null ? null : rgbToHsl(rgb[0], rgb[1], rgb[2]);
    }

    /** {h,s,l} → "#rrggbb". */
    public static String hslToHex(double h, double s, double l) {
        double[] rgb = hslToRgb(h, s, l);
        return rgbToHex(rgb[0], rgb[1], rgb[2]);
    }

    // --- RGB ↔ HSV ---------------------------------------------------------------

    /** {r,g,b} (0–255) → {h,s,v} with h: 0–360, s/v: 0–100. */
    public static double[] rgbToHsv(double r, double g, double b) {
        double rn = clamp01(r / 255.0), gn = clamp01(g / 255.0), bn = clamp01(b / 255.0);
        double mx = Math.max(rn, Math.max(gn, bn));
        double mn = Math.min(rn, Math.min(gn, bn));
        double d = mx - mn;
        double h = 0.0;
        if (d != 0.0) {
            if (mx == rn)      h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0);
            else if (mx == gn) h = (bn - rn) / d + 2.0;
            else               h = (rn - gn) / d + 4.0;
            h *= 60.0;
        }
        double s = mx == 0.0 ? 0.0 : d / mx;
        return new double[] { roundHalfUp(h), roundHalfUp(s * 100.0), roundHalfUp(mx * 100.0) };
    }

    /** {h,s,v} (h: 0–360, s/v: 0–100) → {r,g,b} (0–255). */
    public static double[] hsvToRgb(double h, double s, double v) {
        double hn = mod360(h);
        double sn = clamp01(s / 100.0);
        double vn = clamp01(v / 100.0);
        double c = vn * sn;
        double x = c * (1.0 - Math.abs(hn / 60.0 % 2.0 - 1.0));
        double m = vn - c;
        double rr, gg, bb;
        if (hn < 60.0) {
            rr = c; gg = x; bb = 0.0;
        } else if (hn < 120.0) {
            rr = x; gg = c; bb = 0.0;
        } else if (hn < 180.0) {
            rr = 0.0; gg = c; bb = x;
        } else if (hn < 240.0) {
            rr = 0.0; gg = x; bb = c;
        } else if (hn < 300.0) {
            rr = x; gg = 0.0; bb = c;
        } else {
            rr = c; gg = 0.0; bb = x;
        }
        return new double[] { (rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0 };
    }

    /** {h,s,v} → "#rrggbb". */
    public static String hsvToHex(double h, double s, double v) {
        double[] rgb = hsvToRgb(h, s, v);
        return rgbToHex(rgb[0], rgb[1], rgb[2]);
    }

    // --- RGB ↔ CMYK --------------------------------------------------------------

    /** {r,g,b} (0–255) → {c,m,y,k} (0–100 each). */
    public static double[] rgbToCmyk(double r, double g, double b) {
        double rn = clamp01(r / 255.0), gn = clamp01(g / 255.0), bn = clamp01(b / 255.0);
        double k = 1.0 - Math.max(rn, Math.max(gn, bn));
        if (k == 1.0) {
            return new double[] { 0.0, 0.0, 0.0, 100.0 };   // pure black — avoid divide-by-zero
        }
        double c = (1.0 - rn - k) / (1.0 - k);
        double m = (1.0 - gn - k) / (1.0 - k);
        double y = (1.0 - bn - k) / (1.0 - k);
        return new double[] {
            roundHalfUp(c * 100.0), roundHalfUp(m * 100.0), roundHalfUp(y * 100.0), roundHalfUp(k * 100.0) };
    }

    /** {c,m,y,k} (0–100 each) → {r,g,b} (0–255). */
    public static double[] cmykToRgb(double c, double m, double y, double k) {
        double cn = clamp01(c / 100.0), mn = clamp01(m / 100.0), yn = clamp01(y / 100.0), kn = clamp01(k / 100.0);
        return new double[] {
            255.0 * (1.0 - cn) * (1.0 - kn),
            255.0 * (1.0 - mn) * (1.0 - kn),
            255.0 * (1.0 - yn) * (1.0 - kn),
        };
    }

    /** {c,m,y,k} → "#rrggbb". */
    public static String cmykToHex(double c, double m, double y, double k) {
        double[] rgb = cmykToRgb(c, m, y, k);
        return rgbToHex(rgb[0], rgb[1], rgb[2]);
    }

    // --- Round-robin normalizer --------------------------------------------------

    /**
     * Resolve any hex into one consistent ColorBundle: the hex is re-derived
     * from its own clamped RGB, then HSL/HSV/CMYK are all computed from that
     * same RGB. This is the single funnel the UI routes every edit through, so
     * the five display formats can never disagree. Returns null for invalid hex.
     */
    public static ColorBundle normalizeColor(String value) {
        double[] rgb = hexToRgb(value);
        if (rgb == null) return null;
        double r = rgb[0], g = rgb[1], b = rgb[2];
        return new ColorBundle(
                rgbToHex(r, g, b),
                rgb,
                rgbToHsl(r, g, b),
                rgbToHsv(r, g, b),
                rgbToCmyk(r, g, b));
    }

    // --- WCAG luminance, contrast & text suggestion ------------------------------

    /** Linearize a single sRGB channel (0–255) per the WCAG 2.x definition. */
    private static double srgbChannel(double c) {
        double s = clamp01(c / 255.0);
        if (s <= 0.03928) return s / 12.92;
        return Math.pow((s + 0.055) / 1.055, 2.4);
    }

    /** WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or null. */
    public static Double relativeLuminance(String value) {
        double[] rgb = hexToRgb(value);
        if (rgb == null) return null;
        return 0.2126 * srgbChannel(rgb[0])
             + 0.7152 * srgbChannel(rgb[1])
             + 0.0722 * srgbChannel(rgb[2]);
    }

    /** WCAG contrast ratio between two hexes (1–21), or null if either is invalid. */
    public static Double contrastRatio(String a, String b) {
        Double la = relativeLuminance(a);
        Double lb = relativeLuminance(b);
        if (la == null || lb == null) return null;
        double hi = Math.max(la, lb);
        double lo = Math.min(la, lb);
        return (hi + 0.05) / (lo + 0.05);
    }

    /** Pick black or white text for maximum legibility on hex, or null if invalid. */
    public static String suggestTextHex(String value) {
        Double l = relativeLuminance(value);
        if (l == null) return null;
        return l > 0.179 ? "#000000" : "#ffffff";
    }

    // --- Closest named CSS color -------------------------------------------------

    /**
     * A curated set of well-known CSS named colors. Kept intentionally to
     * entries whose hex is verifiable from memory — exhaustive tables typed by
     * hand risk shipping wrong data, which unit tests cannot catch.
     */
    private static final String[][] NAMED_COLORS_RAW = {
        {"black", "#000000"}, {"dim gray", "#696969"},
        {"gray", "#808080"}, {"dark gray", "#a9a9a9"},
        {"silver", "#c0c0c0"}, {"light gray", "#d3d3d3"},
        {"gainsboro", "#dcdcdc"}, {"white smoke", "#f5f5f5"},
        {"white", "#ffffff"}, {"snow", "#fffafa"},
        {"ivory", "#fffff0"}, {"seashell", "#fff5ee"},
        {"red", "#ff0000"}, {"crimson", "#dc143c"},
        {"dark red", "#8b0000"},
        {"firebrick", "#b22222"}, {"indian red", "#cd5c5c"},
        {"salmon", "#fa8072"}, {"tomato", "#ff6347"},
        {"coral", "#ff7f50"}, {"orange", "#ffa500"},
        {"dark orange", "#ff8c00"}, {"gold", "#ffd700"},
        {"chocolate", "#d2691e"}, {"brown", "#a52a2a"},
        {"sienna", "#a0522d"}, {"tan", "#d2b48c"},
        {"yellow", "#ffff00"}, {"khaki", "#f0e68c"},
        {"lime", "#00ff00"}, {"lime green", "#32cd32"},
        {"forest green", "#228b22"}, {"sea green", "#2e8b57"},
        {"green", "#008000"}, {"dark green", "#006400"},
        {"spring green", "#00ff7f"}, {"olive", "#808000"},
        {"teal", "#008080"}, {"dark cyan", "#008b8b"},
        {"turquoise", "#40e0d0"}, {"cyan", "#00ffff"},
        {"sky blue", "#87ceeb"},
        {"deep sky blue", "#00bfff"}, {"steel blue", "#4682b4"},
        {"dodger blue", "#1e90ff"}, {"royal blue", "#4169e1"},
        {"blue", "#0000ff"}, {"navy", "#000080"},
        {"midnight blue", "#191970"}, {"indigo", "#4b0082"},
        {"purple", "#800080"}, {"dark violet", "#9400d3"},
        {"blue violet", "#8a2be2"}, {"medium purple", "#9370db"},
        {"orchid", "#da70d6"}, {"violet", "#ee82ee"},
        {"plum", "#dda0dd"}, {"magenta", "#ff00ff"},
        {"deep pink", "#ff1493"}, {"hot pink", "#ff69b4"},
        {"pink", "#ffc0cb"}, {"lavender", "#e6e6fa"},
    };

    /** name, hex, and pre-parsed RGB. */
    private record ResolvedNamed(String name, String hex, double[] rgb) {}

    /** Precompute each named color's RGB once (eager static init, mirroring the
     * TS source) so nearest-match lookups never re-parse. */
    private static final List<ResolvedNamed> NAMED_COLORS = buildNamedColors();

    private static List<ResolvedNamed> buildNamedColors() {
        return Arrays.stream(NAMED_COLORS_RAW)
                .map(e -> new ResolvedNamed(e[0], e[1],
                        Objects.requireNonNull(hexToRgb(e[1]))))   // curated hexes always parse
                .toList();
    }

    /**
     * Closest entry in the named-color table by squared RGB Euclidean distance.
     * Returns null for invalid input. The table is a curated subset, so
     * "closest" is approximate, not a guarantee of identity.
     */
    public static NamedColor nearestNamedColor(String value) {
        double[] rgb = hexToRgb(value);
        if (rgb == null) return null;
        double r = rgb[0], g = rgb[1], b = rgb[2];
        ResolvedNamed best = NAMED_COLORS.get(0);
        double bestD = Double.POSITIVE_INFINITY;
        for (ResolvedNamed entry : NAMED_COLORS) {
            double dr = entry.rgb()[0] - r, dg = entry.rgb()[1] - g, db = entry.rgb()[2] - b;
            double d = dr * dr + dg * dg + db * db;
            if (d < bestD) {
                bestD = d;
                best = entry;
            }
        }
        return new NamedColor(best.name(), best.hex());
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →