Color Picker & Converter — Kotlin 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 Kotlin 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: Kotlin 1.9 (kotlin-stdlib only — no ecosystem dependencies).
*
* 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 kotlin.math.floor
/** A color resolved into every supported space, all mutually consistent. */
data class ColorBundle(
val hex: String, // "#rrggbb" (lowercase) — the canonical handle
val rgb: DoubleArray, // 0–255 each
val hsl: DoubleArray, // h: 0–360, s/l: 0–100
val hsv: DoubleArray, // h: 0–360, s/v: 0–100
val cmyk: DoubleArray, // 0–100 each
)
/** A named CSS color with its hex. */
data class NamedColor(val name: String, val hex: String)
object ColorPicker {
// --- Internal clamp helpers --------------------------------------------------
// Every public function is total: invalid hex → null; out-of-range numbers
// are clamped into their valid interval. minOf/maxOf propagate NaN, so NaN
// is handled explicitly before they ever see it.
private fun clamp(n: Double, lo: Double, hi: Double): Double = when {
n.isNaN() -> lo
n == Double.POSITIVE_INFINITY -> hi
n == Double.NEGATIVE_INFINITY -> lo
else -> minOf(hi, maxOf(lo, n))
}
private fun clamp01(n: Double): Double = clamp(n, 0.0, 1.0)
/** Round half away from zero — mirrors JS Math.round for the non-negative
* values this object rounds. The explicit floor(n + 0.5) form documents
* the parity with the TypeScript source. */
private fun roundHalfUp(n: Double): Double = floor(n + 0.5)
/** Normalize any hue (negative, >360, or NaN) into [0, 360), matching
* JS ((Number(h) || 0) % 360 + 360) % 360. Kotlin's % takes the dividend's
* sign, like JS. */
private fun mod360(h: Double): Double {
val hh = if (h.isNaN()) 0.0 else h
return ((hh % 360.0) + 360.0) % 360.0
}
private fun isHexDigits(s: String): Boolean =
s.isNotEmpty() && s.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
/** Parse exactly two validated ASCII hex digits into 0–255. */
private fun parseHexByte(two: String): Double = two.toInt(16).toDouble()
// --- HEX ↔ RGB ---------------------------------------------------------------
/** Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into [r,g,b],
* or null for anything that is not a 3- or 6-digit hex color. */
fun hexToRgb(value: String): DoubleArray? {
var h = value.trim()
if (h.startsWith("#")) h = h.substring(1) // TS strips exactly one leading '#'.
if (h.length == 3 && isHexDigits(h)) { // expand shorthand #rgb → #rrggbb
h = "${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}"
}
if (h.length != 6 || !isHexDigits(h)) return null
return doubleArrayOf(
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). */
fun rgbToHex(r: Double, g: Double, b: Double): String {
fun byte(n: Double) = "%02x".format(roundHalfUp(clamp(n, 0.0, 255.0)).toInt())
return "#${byte(r)}${byte(g)}${byte(b)}"
}
// --- RGB ↔ HSL ---------------------------------------------------------------
/** [r,g,b] (0–255) → [h,s,l] with h: 0–360, s/l: 0–100. */
fun rgbToHsl(r: Double, g: Double, b: Double): DoubleArray {
val rn = clamp01(r / 255.0)
val gn = clamp01(g / 255.0)
val bn = clamp01(b / 255.0)
val mx = maxOf(rn, gn, bn)
val mn = minOf(rn, gn, bn)
val d = mx - mn
val l = (mx + mn) / 2.0
var h = 0.0
var s = 0.0
if (d != 0.0) {
// Saturation formula branches on which half of the lightness axis we sit on.
s = if (l > 0.5) d / (2.0 - mx - mn) else d / (mx + mn)
h = when {
mx == rn -> (gn - bn) / d + (if (gn < bn) 6.0 else 0.0)
mx == gn -> (bn - rn) / d + 2.0
else -> (rn - gn) / d + 4.0
}
h *= 60.0
}
return doubleArrayOf(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). */
fun hslToRgb(h: Double, s: Double, l: Double): DoubleArray {
val hn = mod360(h)
val sn = clamp01(s / 100.0)
val ln = clamp01(l / 100.0)
val c = (1.0 - kotlin.math.abs(2.0 * ln - 1.0)) * sn
val x = c * (1.0 - kotlin.math.abs(hn / 60.0 % 2.0 - 1.0))
val m = ln - c / 2.0
val (rr, gg, bb) = when {
hn < 60.0 -> Triple(c, x, 0.0)
hn < 120.0 -> Triple(x, c, 0.0)
hn < 180.0 -> Triple(0.0, c, x)
hn < 240.0 -> Triple(0.0, x, c)
hn < 300.0 -> Triple(x, 0.0, c)
else -> Triple(c, 0.0, x)
}
return doubleArrayOf((rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0)
}
/** "#hex" → [h,s,l], or null when the hex is invalid. */
fun hexToHsl(value: String): DoubleArray? {
val rgb = hexToRgb(value) ?: return null
return rgbToHsl(rgb[0], rgb[1], rgb[2])
}
/** [h,s,l] → "#rrggbb". */
fun hslToHex(h: Double, s: Double, l: Double): String {
val (r, g, b) = hslToRgb(h, s, l)
return rgbToHex(r, g, b)
}
// --- RGB ↔ HSV ---------------------------------------------------------------
/** [r,g,b] (0–255) → [h,s,v] with h: 0–360, s/v: 0–100. */
fun rgbToHsv(r: Double, g: Double, b: Double): DoubleArray {
val rn = clamp01(r / 255.0)
val gn = clamp01(g / 255.0)
val bn = clamp01(b / 255.0)
val mx = maxOf(rn, gn, bn)
val mn = minOf(rn, gn, bn)
val d = mx - mn
var h = 0.0
if (d != 0.0) {
h = when {
mx == rn -> (gn - bn) / d + (if (gn < bn) 6.0 else 0.0)
mx == gn -> (bn - rn) / d + 2.0
else -> (rn - gn) / d + 4.0
}
h *= 60.0
}
val s = if (mx == 0.0) 0.0 else d / mx
return doubleArrayOf(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). */
fun hsvToRgb(h: Double, s: Double, v: Double): DoubleArray {
val hn = mod360(h)
val sn = clamp01(s / 100.0)
val vn = clamp01(v / 100.0)
val c = vn * sn
val x = c * (1.0 - kotlin.math.abs(hn / 60.0 % 2.0 - 1.0))
val m = vn - c
val (rr, gg, bb) = when {
hn < 60.0 -> Triple(c, x, 0.0)
hn < 120.0 -> Triple(x, c, 0.0)
hn < 180.0 -> Triple(0.0, c, x)
hn < 240.0 -> Triple(0.0, x, c)
hn < 300.0 -> Triple(x, 0.0, c)
else -> Triple(c, 0.0, x)
}
return doubleArrayOf((rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0)
}
/** [h,s,v] → "#rrggbb". */
fun hsvToHex(h: Double, s: Double, v: Double): String {
val (r, g, b) = hsvToRgb(h, s, v)
return rgbToHex(r, g, b)
}
// --- RGB ↔ CMYK --------------------------------------------------------------
/** [r,g,b] (0–255) → [c,m,y,k] (0–100 each). */
fun rgbToCmyk(r: Double, g: Double, b: Double): DoubleArray {
val rn = clamp01(r / 255.0)
val gn = clamp01(g / 255.0)
val bn = clamp01(b / 255.0)
val k = 1.0 - maxOf(rn, gn, bn)
if (k == 1.0) {
return doubleArrayOf(0.0, 0.0, 0.0, 100.0) // pure black — avoid divide-by-zero
}
val c = (1.0 - rn - k) / (1.0 - k)
val m = (1.0 - gn - k) / (1.0 - k)
val y = (1.0 - bn - k) / (1.0 - k)
return doubleArrayOf(
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). */
fun cmykToRgb(c: Double, m: Double, y: Double, k: Double): DoubleArray {
val cn = clamp01(c / 100.0)
val mn = clamp01(m / 100.0)
val yn = clamp01(y / 100.0)
val kn = clamp01(k / 100.0)
return doubleArrayOf(
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". */
fun cmykToHex(c: Double, m: Double, y: Double, k: Double): String {
val (r, g, b) = cmykToRgb(c, m, y, k)
return rgbToHex(r, g, b)
}
// --- 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. */
fun normalizeColor(value: String): ColorBundle? {
val rgb = hexToRgb(value) ?: return null
val (r, g, b) = rgb
return ColorBundle(
hex = rgbToHex(r, g, b),
rgb = rgb,
hsl = rgbToHsl(r, g, b),
hsv = rgbToHsv(r, g, b),
cmyk = rgbToCmyk(r, g, b),
)
}
// --- WCAG luminance, contrast & text suggestion ------------------------------
/** Linearize a single sRGB channel (0–255) per the WCAG 2.x definition. */
private fun srgbChannel(c: Double): Double {
val s = clamp01(c / 255.0)
return if (s <= 0.03928) s / 12.92
else Math.pow((s + 0.055) / 1.055, 2.4)
}
/** WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or null. */
fun relativeLuminance(value: String): Double? {
val rgb = hexToRgb(value) ?: 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. */
fun contrastRatio(a: String, b: String): Double? {
val la = relativeLuminance(a) ?: return null
val lb = relativeLuminance(b) ?: return null
val hi = maxOf(la, lb)
val lo = minOf(la, lb)
return (hi + 0.05) / (lo + 0.05)
}
/** Pick black or white text for maximum legibility on value, or null if invalid. */
fun suggestTextHex(value: String): String? {
val l = relativeLuminance(value) ?: return null
return if (l > 0.179) "#000000" else "#ffffff"
}
// --- Closest named CSS color -------------------------------------------------
private data class ResolvedNamed(val name: String, val hex: String, val rgb: DoubleArray)
/**
* 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 val namedColorsRaw = arrayOf(
"black" to "#000000", "dim gray" to "#696969",
"gray" to "#808080", "dark gray" to "#a9a9a9",
"silver" to "#c0c0c0", "light gray" to "#d3d3d3",
"gainsboro" to "#dcdcdc", "white smoke" to "#f5f5f5",
"white" to "#ffffff", "snow" to "#fffafa",
"ivory" to "#fffff0", "seashell" to "#fff5ee",
"red" to "#ff0000", "crimson" to "#dc143c",
"dark red" to "#8b0000",
"firebrick" to "#b22222", "indian red" to "#cd5c5c",
"salmon" to "#fa8072", "tomato" to "#ff6347",
"coral" to "#ff7f50", "orange" to "#ffa500",
"dark orange" to "#ff8c00", "gold" to "#ffd700",
"chocolate" to "#d2691e", "brown" to "#a52a2a",
"sienna" to "#a0522d", "tan" to "#d2b48c",
"yellow" to "#ffff00", "khaki" to "#f0e68c",
"lime" to "#00ff00", "lime green" to "#32cd32",
"forest green" to "#228b22", "sea green" to "#2e8b57",
"green" to "#008000", "dark green" to "#006400",
"spring green" to "#00ff7f", "olive" to "#808000",
"teal" to "#008080", "dark cyan" to "#008b8b",
"turquoise" to "#40e0d0", "cyan" to "#00ffff",
"sky blue" to "#87ceeb",
"deep sky blue" to "#00bfff", "steel blue" to "#4682b4",
"dodger blue" to "#1e90ff", "royal blue" to "#4169e1",
"blue" to "#0000ff", "navy" to "#000080",
"midnight blue" to "#191970", "indigo" to "#4b0082",
"purple" to "#800080", "dark violet" to "#9400d3",
"blue violet" to "#8a2be2", "medium purple" to "#9370db",
"orchid" to "#da70d6", "violet" to "#ee82ee",
"plum" to "#dda0dd", "magenta" to "#ff00ff",
"deep pink" to "#ff1493", "hot pink" to "#ff69b4",
"pink" to "#ffc0cb", "lavender" to "#e6e6fa",
)
/** Precompute each named color's RGB once (eager at object init, mirroring
* the TS source) so nearest-match lookups never re-parse. */
private val namedColors: List<ResolvedNamed> = namedColorsRaw.map { (name, hex) ->
ResolvedNamed(name, hex, hexToRgb(hex)!!) // curated hexes always parse
}
/** 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. */
fun nearestNamedColor(value: String): NamedColor? {
val rgb = hexToRgb(value) ?: return null
val (r, g, b) = rgb
var best: ResolvedNamed = namedColors.first()
var bestD = Double.POSITIVE_INFINITY
for (entry in namedColors) {
val dr = entry.rgb[0] - r
val dg = entry.rgb[1] - g
val db = entry.rgb[2] - b
val d = dr * dr + dg * dg + db * db
if (d < bestD) {
bestD = d
best = entry
}
}
return 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 →