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 →