Skip to content

Color Picker & Converter — Ruby 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 Ruby 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: Ruby 3.2 (core only — no gems).
#
# 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
# normalize_color() 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.

module ColorPicker
  # A color resolved into every supported space, all mutually consistent.
  ColorBundle = Data.define(:hex, :rgb, :hsl, :hsv, :cmyk)

  # A named CSS color with its hex.
  NamedColor = Data.define(:name, :hex)

  module_function

  # --- Internal helpers ---------------------------------------------------------
  # Every public function is total: invalid hex → nil; out-of-range numbers are
  # clamped into their valid interval. Ruby's comparison operators return false
  # against NaN, so NaN is handled explicitly first — corrupt input degrades to
  # the lowest valid value instead of poisoning the result.

  def _clamp(n, lo, hi)
    n = n.to_f                                # Integer inputs are coerced, like Python's math.isnan.
    return lo if n.nan?
    return hi if n.infinite? == 1
    return lo if n.infinite? == -1

    n.clamp(lo, hi)
  end

  def _clamp01(n) = _clamp(n, 0.0, 1.0)

  # Round half away from zero — mirrors JS Math.round for the non-negative
  # values this module rounds. Ruby's Float#round rounds half away from zero
  # too, but the explicit floor(n + 0.5) form documents the parity.
  def _round_half_up(n) = (n + 0.5).floor.to_f

  # Normalize any hue (negative, >360, or NaN) into [0, 360), matching
  # JS ((Number(h) || 0) % 360 + 360) % 360. Ruby's % already floors toward
  # the divisor, so the double-wrap is a no-op that mirrors the JS shape.
  def _mod360(h)
    h = h.to_f                                # Integer hues are coerced, like Python's math.isnan.
    h = 0.0 if h.nan?
    ((h % 360.0) + 360.0) % 360.0
  end

  # The two legal hex shapes (3 or 6 hex digits). Used in lieu of fuller parsing
  # to keep validation fast and explicit.
  HEX3_RE = /\A[0-9a-fA-F]{3}\z/
  HEX6_RE = /\A[0-9a-fA-F]{6}\z/

  # --- HEX ↔ RGB ------------------------------------------------------------------

  # Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into [r, g, b],
  # or nil for anything that is not a 3- or 6-digit hex color.
  def hex_to_rgb(value)
    h = value.strip
    h = h[1..] if h.start_with?('#')            # TS strips exactly one leading '#'.
    h = h.chars.flat_map { |ch| [ch, ch] }.join if HEX3_RE.match?(h)
    return nil unless HEX6_RE.match?(h)

    [h[0, 2].to_i(16).to_f, h[2, 2].to_i(16).to_f, h[4, 2].to_i(16).to_f]
  end

  # (r, g, b) (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded).
  def rgb_to_hex(r, g, b)
    bytes = [r, g, b].map { |n| _round_half_up(_clamp(n, 0.0, 255.0)).to_i }
    format('#%02x%02x%02x', *bytes)
  end

  # --- RGB ↔ HSL ------------------------------------------------------------------

  # (r, g, b) (0–255) → [h, s, l] with h: 0–360, s/l: 0–100.
  def rgb_to_hsl(r, g, b)
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    mx = [rn, gn, bn].max
    mn = [rn, gn, bn].min
    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 = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn)
      h = if mx == rn
            (gn - bn) / d + (gn < bn ? 6.0 : 0.0)
          elsif mx == gn
            (bn - rn) / d + 2.0
          else
            (rn - gn) / d + 4.0
          end
      h *= 60.0
    end
    [_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(l * 100.0)]
  end

  # (h, s, l) (h: 0–360, s/l: 0–100) → [r, g, b] (0–255).
  def hsl_to_rgb(h, s, l)
    hn = _mod360(h)
    sn = _clamp01(s / 100.0)
    ln = _clamp01(l / 100.0)
    c = (1.0 - (2.0 * ln - 1.0).abs) * sn
    x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs)
    m = ln - c / 2.0
    rr, gg, bb =
      if hn < 60.0
        [c, x, 0.0]
      elsif hn < 120.0
        [x, c, 0.0]
      elsif hn < 180.0
        [0.0, c, x]
      elsif hn < 240.0
        [0.0, x, c]
      elsif hn < 300.0
        [x, 0.0, c]
      else
        [c, 0.0, x]
      end
    [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
  end

  # "#hex" → [h, s, l], or nil when the hex is invalid.
  def hex_to_hsl(value)
    rgb = hex_to_rgb(value)
    return nil if rgb.nil?

    rgb_to_hsl(*rgb)
  end

  # (h, s, l) → "#rrggbb".
  def hsl_to_hex(h, s, l) = rgb_to_hex(*hsl_to_rgb(h, s, l))

  # --- RGB ↔ HSV ------------------------------------------------------------------

  # (r, g, b) (0–255) → [h, s, v] with h: 0–360, s/v: 0–100.
  def rgb_to_hsv(r, g, b)
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    mx = [rn, gn, bn].max
    mn = [rn, gn, bn].min
    d = mx - mn
    h = 0.0
    if d != 0.0
      h = if mx == rn
            (gn - bn) / d + (gn < bn ? 6.0 : 0.0)
          elsif mx == gn
            (bn - rn) / d + 2.0
          else
            (rn - gn) / d + 4.0
          end
      h *= 60.0
    end
    s = mx == 0.0 ? 0.0 : d / mx
    [_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(mx * 100.0)]
  end

  # (h, s, v) (h: 0–360, s/v: 0–100) → [r, g, b] (0–255).
  def hsv_to_rgb(h, s, v)
    hn = _mod360(h)
    sn = _clamp01(s / 100.0)
    vn = _clamp01(v / 100.0)
    c = vn * sn
    x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs)
    m = vn - c
    rr, gg, bb =
      if hn < 60.0
        [c, x, 0.0]
      elsif hn < 120.0
        [x, c, 0.0]
      elsif hn < 180.0
        [0.0, c, x]
      elsif hn < 240.0
        [0.0, x, c]
      elsif hn < 300.0
        [x, 0.0, c]
      else
        [c, 0.0, x]
      end
    [(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
  end

  # (h, s, v) → "#rrggbb".
  def hsv_to_hex(h, s, v) = rgb_to_hex(*hsv_to_rgb(h, s, v))

  # --- RGB ↔ CMYK -----------------------------------------------------------------

  # (r, g, b) (0–255) → [c, m, y, k] (0–100 each).
  def rgb_to_cmyk(r, g, b)
    rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
    k = 1.0 - [rn, gn, bn].max
    return [0.0, 0.0, 0.0, 100.0] if k == 1.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)
    [_round_half_up(c * 100.0), _round_half_up(m * 100.0), _round_half_up(y * 100.0), _round_half_up(k * 100.0)]
  end

  # (c, m, y, k) (0–100 each) → [r, g, b] (0–255).
  def cmyk_to_rgb(c, m, y, k)
    cn, mn, yn, kn = _clamp01(c / 100.0), _clamp01(m / 100.0), _clamp01(y / 100.0), _clamp01(k / 100.0)
    [
      255.0 * (1.0 - cn) * (1.0 - kn),
      255.0 * (1.0 - mn) * (1.0 - kn),
      255.0 * (1.0 - yn) * (1.0 - kn),
    ]
  end

  # (c, m, y, k) → "#rrggbb".
  def cmyk_to_hex(c, m, y, k) = rgb_to_hex(*cmyk_to_rgb(c, m, y, k))

  # --- 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.
  def normalize_color(value)
    rgb = hex_to_rgb(value)
    return nil if rgb.nil?

    r, g, b = rgb
    ColorBundle.new(
      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),
    )
  end

  # --- WCAG luminance, contrast & text suggestion -----------------------------------

  # Linearize a single sRGB channel (0–255) per the WCAG 2.x definition.
  def _srgb_channel(c)
    s = _clamp01(c / 255.0)
    return s / 12.92 if s <= 0.03928

    ((s + 0.055) / 1.055)**2.4
  end

  # WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or nil.
  def relative_luminance(value)
    rgb = hex_to_rgb(value)
    return nil if rgb.nil?

    r, g, b = rgb
    (0.2126 * _srgb_channel(r)) + (0.7152 * _srgb_channel(g)) + (0.0722 * _srgb_channel(b))
  end

  # WCAG contrast ratio between two hexes (1–21), or nil if either is invalid.
  def contrast_ratio(a, b)
    la = relative_luminance(a)
    lb = relative_luminance(b)
    return nil if la.nil? || lb.nil?

    hi, lo = la >= lb ? [la, lb] : [lb, la]
    (hi + 0.05) / (lo + 0.05)
  end

  # Pick black or white text for maximum legibility on value, or nil if invalid.
  def suggest_text_hex(value)
    l = relative_luminance(value)
    return nil if l.nil?

    l > 0.179 ? '#000000' : '#ffffff'
  end

  # --- 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'],
  ].freeze

  # Precompute each named color's RGB once (lazy on first use), mirroring the
  # TS source's eager resolution so nearest-match lookups never re-parse.
  def _named_colors
    @named_colors ||= NAMED_COLORS_RAW.map do |name, hx|
      [name, hx, hex_to_rgb(hx)]
    end
  end

  # 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.
  def nearest_named_color(value)
    rgb = hex_to_rgb(value)
    return nil if rgb.nil?

    r, g, b = rgb
    best = nil
    best_d = Float::INFINITY
    _named_colors.each do |name, hx, (nr, ng, nb)|
      d = (nr - r)**2 + (ng - g)**2 + (nb - b)**2
      next unless d < best_d

      best_d = d
      best = NamedColor.new(name: name, hex: hx)
    end
    best
  end
end

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 →