Skip to content

Color Picker & Converter — Python 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 Python 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: Python (3.10+; runs on 3.9+ thanks to the future-annotations import).

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 None on invalid hex.

RGB is the canonical hub — every other space converts through it — and
normalize_color() re-derives the hex from its own clamped RGB so the five
display formats can never disagree.

Display source — part of CosmoDev's polyglot tool pages.
"""
from __future__ import annotations

import math
import re
from dataclasses import dataclass
from typing import Optional, Tuple


@dataclass(frozen=True)
class ColorBundle:
    """A color resolved into every supported space, all mutually consistent."""

    hex: str                                    # "#rrggbb" (lowercase) — the canonical handle
    rgb: Tuple[float, float, float]             # 0–255 each
    hsl: Tuple[float, float, float]             # h: 0–360, s/l: 0–100
    hsv: Tuple[float, float, float]             # h: 0–360, s/v: 0–100
    cmyk: Tuple[float, float, float, float]     # 0–100 each


@dataclass(frozen=True)
class NamedColor:
    name: str
    hex: str


# --- Internal helpers --------------------------------------------------------
# Every public function is total: invalid hex → None; out-of-range numbers are
# clamped into their valid interval. Python's min()/max() propagate NaN, so NaN
# is handled explicitly before they ever see it — corrupt input degrades to the
# lowest valid value instead of poisoning the result.

def _clamp(n: float, lo: float, hi: float) -> float:
    if math.isnan(n):
        return lo
    if n == math.inf:
        return hi
    if n == -math.inf:
        return lo
    return min(hi, max(lo, n))


def _clamp01(n: float) -> float:
    return _clamp(n, 0.0, 1.0)


def _round_half_up(n: float) -> float:
    """Round half away from zero — mirrors JS Math.round for the non-negative
    values this module rounds. Python's built-in round() uses banker's rounding
    (round-half-to-even), which would diverge from the TypeScript source on
    exact .5 values.
    """
    return float(math.floor(n + 0.5))


def _mod360(h: float) -> float:
    """Normalize any hue (negative, >360, or NaN) into [0, 360), matching
    JS `((Number(h) || 0) % 360 + 360) % 360`.
    """
    if math.isnan(h):
        h = 0.0
    return ((h % 360.0) + 360.0) % 360.0


# The two legal hex shapes (3 or 6 hex digits). Used in lieu of full regex
# parsing to keep validation fast and explicit.
_HEX3 = re.compile(r"^[0-9a-fA-F]{3}$")
_HEX6 = re.compile(r"^[0-9a-fA-F]{6}$")


# --- HEX ↔ RGB ---------------------------------------------------------------

def hex_to_rgb(value: str) -> Optional[Tuple[float, float, float]]:
    """Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into (r, g, b),
    or None for anything that is not a 3- or 6-digit hex color.
    """
    h = value.strip()
    if h.startswith("#"):          # TS strips exactly one leading '#'.
        h = h[1:]
    if _HEX3.match(h):
        h = "".join(ch * 2 for ch in h)
    if not _HEX6.match(h):
        return None
    return (
        float(int(h[0:2], 16)),
        float(int(h[2:4], 16)),
        float(int(h[4:6], 16)),
    )


def rgb_to_hex(r: float, g: float, b: float) -> str:
    """(r, g, b) (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded)."""
    def byte(n: float) -> str:
        return f"{int(_round_half_up(_clamp(n, 0.0, 255.0))):02x}"
    return f"#{byte(r)}{byte(g)}{byte(b)}"


# --- RGB ↔ HSL ---------------------------------------------------------------

def rgb_to_hsl(r: float, g: float, b: float) -> Tuple[float, float, float]:
    """(r, g, b) (0–255) → (h, s, l) with h: 0–360, s/l: 0–100."""
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    mx = max(rn, gn, bn)
    mn = min(rn, gn, bn)
    d = mx - mn
    l = (mx + mn) / 2.0
    h = 0.0
    s = 0.0
    if d != 0.0:
        # Saturation formula branches on which half of the lightness axis we sit on.
        s = d / (2.0 - mx - mn) if l > 0.5 else d / (mx + mn)
        if mx == rn:
            h = (gn - bn) / d + (6.0 if gn < bn else 0.0)
        elif mx == gn:
            h = (bn - rn) / d + 2.0
        else:
            h = (rn - gn) / d + 4.0
        h *= 60.0
    return (_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(l * 100.0))


def hsl_to_rgb(h: float, s: float, l: float) -> Tuple[float, float, float]:
    """(h, s, l) (h: 0–360, s/l: 0–100) → (r, g, b) (0–255)."""
    hn = _mod360(h)
    sn = _clamp01(s / 100.0)
    ln = _clamp01(l / 100.0)
    c = (1.0 - abs(2.0 * ln - 1.0)) * sn
    x = c * (1.0 - abs((hn / 60.0) % 2.0 - 1.0))
    m = ln - c / 2.0
    if hn < 60.0:
        rr, gg, bb = c, x, 0.0
    elif hn < 120.0:
        rr, gg, bb = x, c, 0.0
    elif hn < 180.0:
        rr, gg, bb = 0.0, c, x
    elif hn < 240.0:
        rr, gg, bb = 0.0, x, c
    elif hn < 300.0:
        rr, gg, bb = x, 0.0, c
    else:
        rr, gg, bb = c, 0.0, x
    return ((rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0)


def hex_to_hsl(value: str) -> Optional[Tuple[float, float, float]]:
    """"#hex" → (h, s, l), or None when the hex is invalid."""
    rgb = hex_to_rgb(value)
    return None if rgb is None else rgb_to_hsl(*rgb)


def hsl_to_hex(h: float, s: float, l: float) -> str:
    """(h, s, l) → "#rrggbb"."""
    return rgb_to_hex(*hsl_to_rgb(h, s, l))


# --- RGB ↔ HSV ---------------------------------------------------------------

def rgb_to_hsv(r: float, g: float, b: float) -> Tuple[float, float, float]:
    """(r, g, b) (0–255) → (h, s, v) with h: 0–360, s/v: 0–100."""
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    mx = max(rn, gn, bn)
    mn = min(rn, gn, bn)
    d = mx - mn
    h = 0.0
    if d != 0.0:
        if mx == rn:
            h = (gn - bn) / d + (6.0 if gn < bn else 0.0)
        elif mx == gn:
            h = (bn - rn) / d + 2.0
        else:
            h = (rn - gn) / d + 4.0
        h *= 60.0
    s = 0.0 if mx == 0.0 else d / mx
    return (_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(mx * 100.0))


def hsv_to_rgb(h: float, s: float, v: float) -> Tuple[float, float, float]:
    """(h, s, v) (h: 0–360, s/v: 0–100) → (r, g, b) (0–255)."""
    hn = _mod360(h)
    sn = _clamp01(s / 100.0)
    vn = _clamp01(v / 100.0)
    c = vn * sn
    x = c * (1.0 - abs((hn / 60.0) % 2.0 - 1.0))
    m = vn - c
    if hn < 60.0:
        rr, gg, bb = c, x, 0.0
    elif hn < 120.0:
        rr, gg, bb = x, c, 0.0
    elif hn < 180.0:
        rr, gg, bb = 0.0, c, x
    elif hn < 240.0:
        rr, gg, bb = 0.0, x, c
    elif hn < 300.0:
        rr, gg, bb = x, 0.0, c
    else:
        rr, gg, bb = c, 0.0, x
    return ((rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0)


def hsv_to_hex(h: float, s: float, v: float) -> str:
    """(h, s, v) → "#rrggbb"."""
    return rgb_to_hex(*hsv_to_rgb(h, s, v))


# --- RGB ↔ CMYK --------------------------------------------------------------

def rgb_to_cmyk(r: float, g: float, b: float) -> Tuple[float, float, float, float]:
    """(r, g, b) (0–255) → (c, m, y, k) (0–100 each)."""
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    k = 1.0 - max(rn, gn, bn)
    if k == 1.0:
        return (0.0, 0.0, 0.0, 100.0)      # pure black — avoid divide-by-zero
    c = (1.0 - rn - k) / (1.0 - k)
    m = (1.0 - gn - k) / (1.0 - k)
    y = (1.0 - bn - k) / (1.0 - k)
    return (
        _round_half_up(c * 100.0),
        _round_half_up(m * 100.0),
        _round_half_up(y * 100.0),
        _round_half_up(k * 100.0),
    )


def cmyk_to_rgb(c: float, m: float, y: float, k: float) -> Tuple[float, float, float]:
    """(c, m, y, k) (0–100 each) → (r, g, b) (0–255)."""
    cn, mn, yn, kn = _clamp01(c / 100.0), _clamp01(m / 100.0), _clamp01(y / 100.0), _clamp01(k / 100.0)
    return (
        255.0 * (1.0 - cn) * (1.0 - kn),
        255.0 * (1.0 - mn) * (1.0 - kn),
        255.0 * (1.0 - yn) * (1.0 - kn),
    )


def cmyk_to_hex(c: float, m: float, y: float, k: float) -> str:
    """(c, m, y, k) → "#rrggbb"."""
    return rgb_to_hex(*cmyk_to_rgb(c, m, y, k))


# --- Round-robin normalizer --------------------------------------------------

def normalize_color(value: str) -> Optional[ColorBundle]:
    """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 None for invalid hex.
    """
    rgb = hex_to_rgb(value)
    if rgb is None:
        return None
    r, g, b = rgb
    return ColorBundle(
        hex=rgb_to_hex(r, g, b),
        rgb=rgb,
        hsl=rgb_to_hsl(r, g, b),
        hsv=rgb_to_hsv(r, g, b),
        cmyk=rgb_to_cmyk(r, g, b),
    )


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

def _srgb_channel(c: float) -> float:
    """Linearize a single sRGB channel (0–255) per the WCAG 2.x definition."""
    s = _clamp01(c / 255.0)
    if s <= 0.03928:
        return s / 12.92
    return ((s + 0.055) / 1.055) ** 2.4


def relative_luminance(value: str) -> Optional[float]:
    """WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or None."""
    rgb = hex_to_rgb(value)
    if rgb is None:
        return None
    r, g, b = rgb
    return 0.2126 * _srgb_channel(r) + 0.7152 * _srgb_channel(g) + 0.0722 * _srgb_channel(b)


def contrast_ratio(a: str, b: str) -> Optional[float]:
    """WCAG contrast ratio between two hexes (1–21), or None if either is invalid."""
    la = relative_luminance(a)
    lb = relative_luminance(b)
    if la is None or lb is None:
        return None
    hi, lo = (la, lb) if la >= lb else (lb, la)
    return (hi + 0.05) / (lo + 0.05)


def suggest_text_hex(value: str) -> Optional[str]:
    """Pick black or white text for maximum legibility on `value`, or None if invalid."""
    l = relative_luminance(value)
    if l is None:
        return None
    return "#000000" if l > 0.179 else "#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.
_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"),
]

# Precompute each named color's RGB once at import, mirroring the TS source's
# eager resolution so nearest-match lookups never re-parse.
_NAMED_COLORS = [
    (name, hx, hex_to_rgb(hx))  # type: ignore[arg-type]  # curated hexes are valid
    for name, hx in _NAMED_COLORS_RAW
]


def nearest_named_color(value: str) -> Optional[NamedColor]:
    """Closest entry in the named-color table by squared RGB Euclidean distance.
    Returns None for invalid input. The table is a curated subset, so "closest"
    is approximate, not a guarantee of identity.
    """
    rgb = hex_to_rgb(value)
    if rgb is None:
        return None
    r, g, b = rgb
    best = None
    best_d = math.inf
    for name, hx, (nr, ng, nb) in _NAMED_COLORS:
        d = (nr - r) ** 2 + (ng - g) ** 2 + (nb - b) ** 2
        if d < best_d:
            best_d = d
            best = (name, hx)
    return NamedColor(name=best[0], hex=best[1])  # type: ignore[index]

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 →