Skip to content

Color Picker & Converter — Swift 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 Swift 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: Swift 5.9 (standard library; Foundation is imported only for pow
// and string trimming — it ships with every Swift toolchain, no third-party
// dependency is involved).
//
// 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 nil 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 Foundation

/// A color resolved into every supported space, all mutually consistent.
struct ColorBundle: Equatable {
    let hex: String                 // "#rrggbb" (lowercase) — the canonical handle
    let rgb: [Double]               // 0–255 each
    let hsl: [Double]               // h: 0–360, s/l: 0–100
    let hsv: [Double]               // h: 0–360, s/v: 0–100
    let cmyk: [Double]              // 0–100 each
}

/// A named CSS color with its hex.
struct NamedColor: Equatable {
    let name: String
    let hex: String
}

// MARK: - Internal clamp helpers

// Every public function is total: invalid hex → nil; out-of-range numbers are
// clamped into their valid interval. Swift's min()/max() are NaN-unsafe, so
// NaN is handled explicitly before they ever see it.

private func clamp(_ n: Double, _ lo: Double, _ hi: Double) -> Double {
    if n.isNaN { return lo }
    if n == .infinity { return hi }
    if n == -.infinity { return lo }
    return Swift.min(hi, Swift.max(lo, n))
}

private func 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 file rounds. rounded(.toNearestOrAwayFromZero) would match too;
/// the explicit floor form documents the parity with the TypeScript source.
private func roundHalfUp(_ n: Double) -> Double { (n + 0.5).rounded(.down) }

/// Normalize any hue (negative, >360, or NaN) into [0, 360), matching
/// JS ((Number(h) || 0) % 360 + 360) % 360. truncatingRemainder matches the
/// sign behavior of JS %.
private func mod360(_ h: Double) -> Double {
    let hh = h.isNaN ? 0.0 : h
    return (hh.truncatingRemainder(dividingBy: 360.0) + 360.0).truncatingRemainder(dividingBy: 360.0)
}

// MARK: - HEX ↔ RGB

private func isHexDigits(_ s: Substring) -> Bool {
    !s.isEmpty && s.allSatisfy { $0.isHexDigit }
}

/// Parse exactly two validated ASCII hex digits into 0–255.
/// Force-unwraps are safe: the caller validates the characters first.
private func parseHexByte(_ s: Substring) -> Double {
    Double(Int(s, radix: 16)!)
}

/// Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into [r, g, b],
/// or nil for anything that is not a 3- or 6-digit hex color.
func hexToRgb(_ value: String) -> [Double]? {
    var h = Substring(value.trimmingCharacters(in: .whitespacesAndNewlines))

    if h.hasPrefix("#") { h = h.dropFirst() }             // TS strips exactly one leading '#'.

    if h.count == 3, isHexDigits(h) {                     // expand shorthand #rgb → #rrggbb
        h = Substring(String(h.flatMap { [$0, $0] }))
    }
    guard h.count == 6, isHexDigits(h) else { return nil }
    return [
        parseHexByte(h.prefix(2)),
        parseHexByte(h.dropFirst(2).prefix(2)),
        parseHexByte(h.suffix(2)),
    ]
}

/// [r, g, b] (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded).
func rgbToHex(_ r: Double, _ g: Double, _ b: Double) -> String {
    func byte(_ n: Double) -> String {
        // The clamped, rounded value always lands in 0...255, so at most two
        // hex digits — pad to exactly two.
        let s = String(Int(roundHalfUp(clamp(n, 0.0, 255.0))), radix: 16)
        return s.count == 1 ? "0" + s : s
    }
    return "#" + byte(r) + byte(g) + byte(b)
}

// MARK: - RGB ↔ HSL

/// [r, g, b] (0–255) → [h, s, l] with h: 0–360, s/l: 0–100.
func rgbToHsl(_ r: Double, _ g: Double, _ b: Double) -> [Double] {
    let rn = clamp01(r / 255.0)
    let gn = clamp01(g / 255.0)
    let bn = clamp01(b / 255.0)
    let mx = Swift.max(rn, gn, bn)
    let mn = Swift.min(rn, gn, bn)
    let d = mx - mn
    let 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 = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn)
        if mx == rn {
            h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0)
        } else if mx == gn {
            h = (bn - rn) / d + 2.0
        } else {
            h = (rn - gn) / d + 4.0
        }
        h *= 60.0
    }
    return [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).
func hslToRgb(_ h: Double, _ s: Double, _ l: Double) -> [Double] {
    let hn = mod360(h)
    let sn = clamp01(s / 100.0)
    let ln = clamp01(l / 100.0)
    let c = (1.0 - abs(2.0 * ln - 1.0)) * sn
    let x = c * (1.0 - abs((hn / 60.0).truncatingRemainder(dividingBy: 2.0) - 1.0))
    let m = ln - c / 2.0
    return sectorToRgb(hn: hn, c: c, x: x, m: m)
}

/// "#hex" → [h, s, l], or nil when the hex is invalid.
func hexToHsl(_ value: String) -> [Double]? {
    guard let rgb = hexToRgb(value) else { return nil }
    return rgbToHsl(rgb[0], rgb[1], rgb[2])
}

/// [h, s, l] → "#rrggbb".
func hslToHex(_ h: Double, _ s: Double, _ l: Double) -> String {
    let rgb = hslToRgb(h, s, l)
    return rgbToHex(rgb[0], rgb[1], rgb[2])
}

// MARK: - RGB ↔ HSV

/// [r, g, b] (0–255) → [h, s, v] with h: 0–360, s/v: 0–100.
func rgbToHsv(_ r: Double, _ g: Double, _ b: Double) -> [Double] {
    let rn = clamp01(r / 255.0)
    let gn = clamp01(g / 255.0)
    let bn = clamp01(b / 255.0)
    let mx = Swift.max(rn, gn, bn)
    let mn = Swift.min(rn, gn, bn)
    let d = mx - mn
    var h = 0.0
    if d != 0.0 {
        if mx == rn {
            h = (gn - bn) / d + (gn < bn ? 6.0 : 0.0)
        } else if mx == gn {
            h = (bn - rn) / d + 2.0
        } else {
            h = (rn - gn) / d + 4.0
        }
        h *= 60.0
    }
    let s = mx == 0.0 ? 0.0 : d / mx
    return [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).
func hsvToRgb(_ h: Double, _ s: Double, _ v: Double) -> [Double] {
    let hn = mod360(h)
    let sn = clamp01(s / 100.0)
    let vn = clamp01(v / 100.0)
    let c = vn * sn
    let x = c * (1.0 - abs((hn / 60.0).truncatingRemainder(dividingBy: 2.0) - 1.0))
    let m = vn - c
    return sectorToRgb(hn: hn, c: c, x: x, m: m)
}

/// [h, s, v] → "#rrggbb".
func hsvToHex(_ h: Double, _ s: Double, _ v: Double) -> String {
    let rgb = hsvToRgb(h, s, v)
    return rgbToHex(rgb[0], rgb[1], rgb[2])
}

/// Shared six-sector chroma mapping used by both HSL and HSV: pick the (r', g',
/// b') triple for a hue sector, then offset by m and scale to 0–255.
private func sectorToRgb(hn: Double, c: Double, x: Double, m: Double) -> [Double] {
    let (rr, gg, bb): (Double, Double, Double)
    if hn < 60.0 {
        (rr, gg, bb) = (c, x, 0.0)
    } else if hn < 120.0 {
        (rr, gg, bb) = (x, c, 0.0)
    } else if hn < 180.0 {
        (rr, gg, bb) = (0.0, c, x)
    } else if hn < 240.0 {
        (rr, gg, bb) = (0.0, x, c)
    } else if 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]
}

// MARK: - RGB ↔ CMYK

/// [r, g, b] (0–255) → [c, m, y, k] (0–100 each).
func rgbToCmyk(_ r: Double, _ g: Double, _ b: Double) -> [Double] {
    let rn = clamp01(r / 255.0)
    let gn = clamp01(g / 255.0)
    let bn = clamp01(b / 255.0)
    let k = 1.0 - Swift.max(rn, gn, bn)
    if k == 1.0 {
        return [0.0, 0.0, 0.0, 100.0]                     // pure black — avoid divide-by-zero
    }
    let c = (1.0 - rn - k) / (1.0 - k)
    let m = (1.0 - gn - k) / (1.0 - k)
    let y = (1.0 - bn - k) / (1.0 - k)
    return [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).
func cmykToRgb(_ c: Double, _ m: Double, _ y: Double, _ k: Double) -> [Double] {
    let cn = clamp01(c / 100.0)
    let mn = clamp01(m / 100.0)
    let yn = clamp01(y / 100.0)
    let kn = 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".
func cmykToHex(_ c: Double, _ m: Double, _ y: Double, _ k: Double) -> String {
    let rgb = cmykToRgb(c, m, y, k)
    return rgbToHex(rgb[0], rgb[1], rgb[2])
}

// MARK: - 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 nil for invalid hex.
func normalizeColor(_ value: String) -> ColorBundle? {
    guard let rgb = hexToRgb(value) else { return nil }
    let r = rgb[0], g = rgb[1], b = rgb[2]
    return ColorBundle(
        hex: rgbToHex(r, g, b),
        rgb: rgb,
        hsl: rgbToHsl(r, g, b),
        hsv: rgbToHsv(r, g, b),
        cmyk: rgbToCmyk(r, g, b))
}

// MARK: - WCAG luminance, contrast & text suggestion

/// Linearize a single sRGB channel (0–255) per the WCAG 2.x relative-luminance
/// definition.
private func srgbChannel(_ c: Double) -> Double {
    let s = clamp01(c / 255.0)
    if s <= 0.03928 { return s / 12.92 }
    return pow((s + 0.055) / 1.055, 2.4)
}

/// WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or nil.
func relativeLuminance(_ value: String) -> Double? {
    guard let rgb = hexToRgb(value) else { return nil }
    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 nil if either is invalid.
func contrastRatio(_ a: String, _ b: String) -> Double? {
    guard let la = relativeLuminance(a), let lb = relativeLuminance(b) else { return nil }
    let hi = Swift.max(la, lb)
    let lo = Swift.min(la, lb)
    return (hi + 0.05) / (lo + 0.05)
}

/// Pick black or white text for maximum legibility on value, or nil if invalid.
func suggestTextHex(_ value: String) -> String? {
    guard let l = relativeLuminance(value) else { return nil }
    return l > 0.179 ? "#000000" : "#ffffff"
}

// MARK: - 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 let namedColorsRaw: [(name: String, hex: String)] = [
    ("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"),
]

/// Pre-resolved named color: name, hex, and parsed RGB.
private struct ResolvedNamed {
    let name: String
    let hex: String
    let rgb: [Double]
}

// A file-scope `let` is evaluated lazily, exactly once (thread-safe), so the
// table parses on first lookup and is shared thereafter — the Swift analogue
// of the TS source's eager module-init resolution.
private let namedColors: [ResolvedNamed] = namedColorsRaw.map { entry in
    ResolvedNamed(name: entry.name, hex: entry.hex, rgb: hexToRgb(entry.hex)!)  // curated hexes always parse
}

/// Closest entry in the named-color table by squared RGB Euclidean distance.
/// Returns nil for invalid input. The table is a curated subset, so "closest"
/// is approximate, not a guarantee of identity.
func nearestNamedColor(_ value: String) -> NamedColor? {
    guard let rgb = hexToRgb(value) else { return nil }
    let r = rgb[0], g = rgb[1], b = rgb[2]
    var best = namedColors[0]
    var bestD = Double.infinity
    for entry in namedColors {
        let dr = entry.rgb[0] - r
        let dg = entry.rgb[1] - g
        let db = entry.rgb[2] - b
        let d = dr * dr + dg * dg + db * db
        if d < bestD {
            bestD = d
            best = entry
        }
    }
    return NamedColor(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 →