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 →