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 →