Skip to content

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 →