Skip to content

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 →