Color Picker & Converter — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* color-picker — color-space conversions, WCAG contrast, and named-color lookup.
*
* Language: PHP.
*
* 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.
*
* Display source — part of CosmoDev's polyglot tool pages.
*/
declare(strict_types=1);
namespace CosmoDev\ColorPicker;
/**
* Pure color-conversion helpers. Every public method is total: invalid hex
* returns null; out-of-range numbers are clamped into their valid interval.
*/
final class ColorPicker
{
// --- Internal clamp helpers ----------------------------------------------
// min()/max() propagate NaN in PHP, so NaN is handled explicitly before they
// ever see it — corrupt input degrades to the lowest valid value instead of
// poisoning the result.
/** Clamp $n to [$min, $max]. NaN → $min; ±INF snap to the nearer bound. */
private static function clamp(float $n, float $min, float $max): float
{
if (is_nan($n)) {
return $min;
}
if ($n === INF) {
return $max;
}
if ($n === -INF) {
return $min;
}
return min(max($n, $min), $max);
}
private static function clamp01(float $n): float
{
return self::clamp($n, 0.0, 1.0);
}
/**
* Normalize a hue (negative, >360, or NaN) into [0, 360), matching
* JS `((Number(h) || 0) % 360 + 360) % 360`. fmod() follows the dividend's
* sign just like JS `%`.
*/
private static function mod360(float $h): float
{
if (is_nan($h)) {
$h = 0.0;
}
return fmod(fmod($h, 360.0) + 360.0, 360.0);
}
// --- HEX ↔ RGB -----------------------------------------------------------
/**
* Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into [r, g, b].
* Returns null when the input is not a valid 3- or 6-digit hex color.
*
* @return array{0:float,1:float,2:float}|null
*/
public static function hexToRgb(string $hex): ?array
{
$h = trim($hex);
// TS strips exactly one leading '#'.
if ($h !== '' && $h[0] === '#') {
$h = substr($h, 1);
}
if (preg_match('/^[0-9a-fA-F]{3}$/', $h)) {
// Expand shorthand: each digit doubled (#abc → #aabbcc).
$h = $h[0] . $h[0] . $h[1] . $h[1] . $h[2] . $h[2];
}
if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) {
return null;
}
return [
(float) intval(substr($h, 0, 2), 16),
(float) intval(substr($h, 2, 2), 16),
(float) intval(substr($h, 4, 2), 16),
];
}
/** `[r,g,b]` (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded). */
public static function rgbToHex(float $r, float $g, float $b): string
{
// PHP round() is round-half-away-from-zero by default — same as JS Math.round
// for the non-negative values we clamp into [0, 255].
$byte = static fn (float $n): string => sprintf('%02x', (int) round(self::clamp($n, 0.0, 255.0)));
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.
*
* @return array{0:float,1:float,2:float}
*/
public static function rgbToHsl(float $r, float $g, float $b): array
{
$rn = self::clamp01($r / 255.0);
$gn = self::clamp01($g / 255.0);
$bn = self::clamp01($b / 255.0);
$max = max($rn, $gn, $bn);
$min = min($rn, $gn, $bn);
$d = $max - $min;
$l = ($max + $min) / 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 / ($l > 0.5 ? 2.0 - $max - $min : $max + $min);
if ($max === $rn) {
$h = ($gn - $bn) / $d + ($gn < $bn ? 6.0 : 0.0);
} elseif ($max === $gn) {
$h = ($bn - $rn) / $d + 2.0;
} else {
$h = ($rn - $gn) / $d + 4.0;
}
$h *= 60.0;
}
return [round($h), round($s * 100.0), round($l * 100.0)];
}
/**
* `[h,s,l]` (h: 0–360, s/l: 0–100) → `[r,g,b]` (0–255).
*
* @return array{0:float,1:float,2:float}
*/
public static function hslToRgb(float $h, float $s, float $l): array
{
$hn = self::mod360($h);
$sn = self::clamp01($s / 100.0);
$ln = self::clamp01($l / 100.0);
$c = (1.0 - abs(2.0 * $ln - 1.0)) * $sn;
$x = $c * (1.0 - abs(fmod($hn / 60.0, 2.0) - 1.0));
$m = $ln - $c / 2.0;
if ($hn < 60.0) {
[$r, $g, $b] = [$c, $x, 0.0];
} elseif ($hn < 120.0) {
[$r, $g, $b] = [$x, $c, 0.0];
} elseif ($hn < 180.0) {
[$r, $g, $b] = [0.0, $c, $x];
} elseif ($hn < 240.0) {
[$r, $g, $b] = [0.0, $x, $c];
} elseif ($hn < 300.0) {
[$r, $g, $b] = [$x, 0.0, $c];
} else {
[$r, $g, $b] = [$c, 0.0, $x];
}
return [($r + $m) * 255.0, ($g + $m) * 255.0, ($b + $m) * 255.0];
}
/** "#hex" → `[h,s,l]`, or null when the hex is invalid. */
public static function hexToHsl(string $hex): ?array
{
$rgb = self::hexToRgb($hex);
return $rgb === null ? null : self::rgbToHsl($rgb[0], $rgb[1], $rgb[2]);
}
/** `[h,s,l]` → "#rrggbb". */
public static function hslToHex(float $h, float $s, float $l): string
{
[$r, $g, $b] = self::hslToRgb($h, $s, $l);
return self::rgbToHex($r, $g, $b);
}
// --- RGB ↔ HSV -----------------------------------------------------------
/**
* `[r,g,b]` (0–255) → `[h,s,v]` with h: 0–360, s/v: 0–100.
*
* @return array{0:float,1:float,2:float}
*/
public static function rgbToHsv(float $r, float $g, float $b): array
{
$rn = self::clamp01($r / 255.0);
$gn = self::clamp01($g / 255.0);
$bn = self::clamp01($b / 255.0);
$max = max($rn, $gn, $bn);
$min = min($rn, $gn, $bn);
$d = $max - $min;
$h = 0.0;
if ($d != 0.0) {
if ($max === $rn) {
$h = ($gn - $bn) / $d + ($gn < $bn ? 6.0 : 0.0);
} elseif ($max === $gn) {
$h = ($bn - $rn) / $d + 2.0;
} else {
$h = ($rn - $gn) / $d + 4.0;
}
$h *= 60.0;
}
$s = $max == 0.0 ? 0.0 : $d / $max;
return [round($h), round($s * 100.0), round($max * 100.0)];
}
/**
* `[h,s,v]` (h: 0–360, s/v: 0–100) → `[r,g,b]` (0–255).
*
* @return array{0:float,1:float,2:float}
*/
public static function hsvToRgb(float $h, float $s, float $v): array
{
$hn = self::mod360($h);
$sn = self::clamp01($s / 100.0);
$vn = self::clamp01($v / 100.0);
$c = $vn * $sn;
$x = $c * (1.0 - abs(fmod($hn / 60.0, 2.0) - 1.0));
$m = $vn - $c;
if ($hn < 60.0) {
[$r, $g, $b] = [$c, $x, 0.0];
} elseif ($hn < 120.0) {
[$r, $g, $b] = [$x, $c, 0.0];
} elseif ($hn < 180.0) {
[$r, $g, $b] = [0.0, $c, $x];
} elseif ($hn < 240.0) {
[$r, $g, $b] = [0.0, $x, $c];
} elseif ($hn < 300.0) {
[$r, $g, $b] = [$x, 0.0, $c];
} else {
[$r, $g, $b] = [$c, 0.0, $x];
}
return [($r + $m) * 255.0, ($g + $m) * 255.0, ($b + $m) * 255.0];
}
/** `[h,s,v]` → "#rrggbb". */
public static function hsvToHex(float $h, float $s, float $v): string
{
[$r, $g, $b] = self::hsvToRgb($h, $s, $v);
return self::rgbToHex($r, $g, $b);
}
// --- RGB ↔ CMYK ----------------------------------------------------------
/**
* `[r,g,b]` (0–255) → `[c,m,y,k]` (0–100 each).
*
* @return array{0:float,1:float,2:float,3:float}
*/
public static function rgbToCmyk(float $r, float $g, float $b): array
{
$rn = self::clamp01($r / 255.0);
$gn = self::clamp01($g / 255.0);
$bn = self::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($c * 100.0), round($m * 100.0), round($y * 100.0), round($k * 100.0)];
}
/**
* `[c,m,y,k]` (0–100 each) → `[r,g,b]` (0–255).
*
* @return array{0:float,1:float,2:float}
*/
public static function cmykToRgb(float $c, float $m, float $y, float $k): array
{
$cn = self::clamp01($c / 100.0);
$mn = self::clamp01($m / 100.0);
$yn = self::clamp01($y / 100.0);
$kn = self::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),
];
}
/** `[c,m,y,k]` → "#rrggbb". */
public static function cmykToHex(float $c, float $m, float $y, float $k): string
{
[$r, $g, $b] = self::cmykToRgb($c, $m, $y, $k);
return self::rgbToHex($r, $g, $b);
}
// --- Round-robin normalizer ----------------------------------------------
/**
* Resolve any hex into one consistent bundle: 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 unparseable input.
*
* @return array{hex:string,rgb:array{0:float,1:float,2:float},hsv:array{0:float,1:float,2:float},hsl:array{0:float,1:float,2:float},cmyk:array{0:float,1:float,2:float,3:float}}|null
*/
public static function normalizeColor(string $hex): ?array
{
$rgb = self::hexToRgb($hex);
if ($rgb === null) {
return null;
}
[$r, $g, $b] = $rgb;
return [
'hex' => self::rgbToHex($r, $g, $b),
'rgb' => [$r, $g, $b],
'hsl' => self::rgbToHsl($r, $g, $b),
'hsv' => self::rgbToHsv($r, $g, $b),
'cmyk' => self::rgbToCmyk($r, $g, $b),
];
}
// --- WCAG luminance, contrast & text suggestion --------------------------
/** Linearize a single sRGB channel (0–255) per the WCAG 2.x definition. */
private static function srgbChannel(float $c): float
{
$s = self::clamp01($c / 255.0);
return $s <= 0.03928 ? $s / 12.92 : pow(($s + 0.055) / 1.055, 2.4);
}
/** WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or null. */
public static function relativeLuminance(string $hex): ?float
{
$rgb = self::hexToRgb($hex);
if ($rgb === null) {
return null;
}
return 0.2126 * self::srgbChannel($rgb[0])
+ 0.7152 * self::srgbChannel($rgb[1])
+ 0.0722 * self::srgbChannel($rgb[2]);
}
/** WCAG contrast ratio between two hexes (1–21), or null if either is invalid. */
public static function contrastRatio(string $a, string $b): ?float
{
$la = self::relativeLuminance($a);
$lb = self::relativeLuminance($b);
if ($la === null || $lb === null) {
return null;
}
$hi = max($la, $lb);
$lo = 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 function suggestTextHex(string $hex): ?string
{
$l = self::relativeLuminance($hex);
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 const NAMED_COLORS_RAW = [
['name' => 'black', 'hex' => '#000000'], ['name' => 'dim gray', 'hex' => '#696969'],
['name' => 'gray', 'hex' => '#808080'], ['name' => 'dark gray', 'hex' => '#a9a9a9'],
['name' => 'silver', 'hex' => '#c0c0c0'], ['name' => 'light gray', 'hex' => '#d3d3d3'],
['name' => 'gainsboro', 'hex' => '#dcdcdc'], ['name' => 'white smoke', 'hex' => '#f5f5f5'],
['name' => 'white', 'hex' => '#ffffff'], ['name' => 'snow', 'hex' => '#fffafa'],
['name' => 'ivory', 'hex' => '#fffff0'], ['name' => 'seashell', 'hex' => '#fff5ee'],
['name' => 'red', 'hex' => '#ff0000'], ['name' => 'crimson', 'hex' => '#dc143c'],
['name' => 'dark red', 'hex' => '#8b0000'],
['name' => 'firebrick', 'hex' => '#b22222'], ['name' => 'indian red', 'hex' => '#cd5c5c'],
['name' => 'salmon', 'hex' => '#fa8072'], ['name' => 'tomato', 'hex' => '#ff6347'],
['name' => 'coral', 'hex' => '#ff7f50'], ['name' => 'orange', 'hex' => '#ffa500'],
['name' => 'dark orange', 'hex' => '#ff8c00'], ['name' => 'gold', 'hex' => '#ffd700'],
['name' => 'chocolate', 'hex' => '#d2691e'], ['name' => 'brown', 'hex' => '#a52a2a'],
['name' => 'sienna', 'hex' => '#a0522d'], ['name' => 'tan', 'hex' => '#d2b48c'],
['name' => 'yellow', 'hex' => '#ffff00'], ['name' => 'khaki', 'hex' => '#f0e68c'],
['name' => 'lime', 'hex' => '#00ff00'], ['name' => 'lime green', 'hex' => '#32cd32'],
['name' => 'forest green', 'hex' => '#228b22'], ['name' => 'sea green', 'hex' => '#2e8b57'],
['name' => 'green', 'hex' => '#008000'], ['name' => 'dark green', 'hex' => '#006400'],
['name' => 'spring green', 'hex' => '#00ff7f'], ['name' => 'olive', 'hex' => '#808000'],
['name' => 'teal', 'hex' => '#008080'], ['name' => 'dark cyan', 'hex' => '#008b8b'],
['name' => 'turquoise', 'hex' => '#40e0d0'], ['name' => 'cyan', 'hex' => '#00ffff'],
['name' => 'sky blue', 'hex' => '#87ceeb'],
['name' => 'deep sky blue', 'hex' => '#00bfff'], ['name' => 'steel blue', 'hex' => '#4682b4'],
['name' => 'dodger blue', 'hex' => '#1e90ff'], ['name' => 'royal blue', 'hex' => '#4169e1'],
['name' => 'blue', 'hex' => '#0000ff'], ['name' => 'navy', 'hex' => '#000080'],
['name' => 'midnight blue', 'hex' => '#191970'], ['name' => 'indigo', 'hex' => '#4b0082'],
['name' => 'purple', 'hex' => '#800080'], ['name' => 'dark violet', 'hex' => '#9400d3'],
['name' => 'blue violet', 'hex' => '#8a2be2'], ['name' => 'medium purple', 'hex' => '#9370db'],
['name' => 'orchid', 'hex' => '#da70d6'], ['name' => 'violet', 'hex' => '#ee82ee'],
['name' => 'plum', 'hex' => '#dda0dd'], ['name' => 'magenta', 'hex' => '#ff00ff'],
['name' => 'deep pink', 'hex' => '#ff1493'], ['name' => 'hot pink', 'hex' => '#ff69b4'],
['name' => 'pink', 'hex' => '#ffc0cb'], ['name' => 'lavender', 'hex' => '#e6e6fa'],
];
/** @var array<int, array{name:string,hex:string,r:float,g:float,b:float}>|null */
private static ?array $namedColors = null;
/**
* Resolve each named color's RGB once (lazy, cached) so nearest-match
* lookups never re-parse.
*
* @return array<int, array{name:string,hex:string,r:float,g:float,b:float}>
*/
private static function namedColors(): array
{
if (self::$namedColors === null) {
$resolved = [];
foreach (self::NAMED_COLORS_RAW as $c) {
[$r, $g, $b] = self::hexToRgb($c['hex']); // known-valid hexes
$resolved[] = ['name' => $c['name'], 'hex' => $c['hex'], 'r' => $r, 'g' => $g, 'b' => $b];
}
self::$namedColors = $resolved;
}
return self::$namedColors;
}
/**
* Closest entry in the named-color table by squared RGB Euclidean distance.
* Returns `{name, hex}`, or null for unparseable input. The table is a
* curated subset, so "closest" is approximate, not a guarantee of identity.
*
* @return array{name:string,hex:string}|null
*/
public static function nearestNamedColor(string $hex): ?array
{
$rgb = self::hexToRgb($hex);
if ($rgb === null) {
return null;
}
[$r, $g, $b] = $rgb;
$best = null;
$bestD = INF;
foreach (self::namedColors() as $c) {
$dr = $c['r'] - $r;
$dg = $c['g'] - $g;
$db = $c['b'] - $b;
$d = $dr * $dr + $dg * $dg + $db * $db;
if ($d < $bestD) {
$bestD = $d;
$best = $c;
}
}
return ['name' => $best['name'], 'hex' => $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 →