Skip to content

Color Picker & Converter — C++ 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 C++ 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: C++17 (C++ standard library only — no ecosystem dependencies).
//
// 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 std::nullopt 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.

#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <iomanip>
#include <limits>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

namespace color_picker {

/// A color resolved into every supported space, all mutually consistent.
struct ColorBundle {
    std::string hex;                    ///< #rrggbb (lowercase) — the canonical handle
    std::array<double, 3> rgb;          ///< 0–255 each
    std::array<double, 3> hsl;          ///< h: 0–360, s/l: 0–100
    std::array<double, 3> hsv;          ///< h: 0–360, s/v: 0–100
    std::array<double, 4> cmyk;         ///< 0–100 each
};

/// A named CSS color with its hex.
struct NamedColor {
    std::string_view name;
    std::string_view hex;
};

namespace detail {

// Every public function is total: invalid hex → nullopt; out-of-range numbers
// are clamped into their valid interval. Comparisons would propagate NaN, so
// NaN is handled explicitly first — corrupt input degrades to the lowest valid
// value instead of poisoning the result.
inline double clamp_range(double n, double lo, double hi) {
    const double inf = std::numeric_limits<double>::infinity();
    if (std::isnan(n)) return lo;
    if (n == inf) return hi;
    if (n == -inf) return lo;
    return std::clamp(n, lo, hi);
}

inline double clamp01(double n) { return clamp_range(n, 0.0, 1.0); }

// Round half away from zero — mirrors JS Math.round for the non-negative
// values this module rounds. std::round would match too; the explicit
// floor(n + 0.5) form documents the parity with the TypeScript source.
inline double round_half_up(double n) { return std::floor(n + 0.5); }

// Normalize any hue (negative, >360, or NaN) into [0, 360), matching
// JS ((Number(h) || 0) % 360 + 360) % 360. std::fmod takes the dividend's
// sign, like JS %.
inline double mod360(double h) {
    if (std::isnan(h)) h = 0.0;
    return std::fmod(std::fmod(h, 360.0) + 360.0, 360.0);
}

inline bool is_hex_digits(std::string_view s) {
    if (s.empty()) return false;
    return std::all_of(s.begin(), s.end(), [](unsigned char c) {
        return std::isxdigit(c) != 0;
    });
}

inline int hex_val(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    return c - 'A' + 10;
}

/// Parse exactly two validated ASCII hex digits into 0–255.
inline double parse_hex_byte(std::string_view s) {
    return static_cast<double>((hex_val(s[0]) << 4) | hex_val(s[1]));
}

}  // namespace detail

// --- HEX ↔ RGB ---------------------------------------------------------------

/// Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into {r,g,b}, or
/// nullopt for anything that is not a 3- or 6-digit hex color.
inline std::optional<std::array<double, 3>> hex_to_rgb(const std::string& hex) {
    std::string h = hex;

    // Trim ASCII whitespace from both ends, mirroring Python str.strip().
    const auto not_space = [](unsigned char c) { return std::isspace(c) == 0; };
    h.erase(h.begin(), std::find_if(h.begin(), h.end(), not_space));
    h.erase(std::find_if(h.rbegin(), h.rend(), not_space).base(), h.end());

    // TS strips exactly one leading '#'.
    if (!h.empty() && h.front() == '#') h.erase(0, 1);

    // Expand shorthand (#rgb → #rrggbb) only when it is exactly three hex digits.
    if (h.size() == 3 && detail::is_hex_digits(h)) {
        const char c0 = h[0], c1 = h[1], c2 = h[2];
        h = {c0, c0, c1, c1, c2, c2};
    }
    if (h.size() != 6 || !detail::is_hex_digits(h)) return std::nullopt;

    return std::array<double, 3>{
        detail::parse_hex_byte(std::string_view(h).substr(0, 2)),
        detail::parse_hex_byte(std::string_view(h).substr(2, 2)),
        detail::parse_hex_byte(std::string_view(h).substr(4, 2)),
    };
}

/// {r,g,b} (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded).
inline std::string rgb_to_hex(double r, double g, double b) {
    std::ostringstream out;
    out << '#';
    for (const double n : {r, g, b}) {
        const auto v = static_cast<unsigned>(detail::round_half_up(detail::clamp_range(n, 0.0, 255.0)));
        out << std::hex << std::setw(2) << std::setfill('0') << v;
    }
    return out.str();
}

// --- RGB ↔ HSL ---------------------------------------------------------------

/// {r,g,b} (0–255) → {h,s,l} with h: 0–360, s/l: 0–100.
inline std::array<double, 3> rgb_to_hsl(double r, double g, double b) {
    const double rn = detail::clamp01(r / 255.0);
    const double gn = detail::clamp01(g / 255.0);
    const double bn = detail::clamp01(b / 255.0);
    const double mx = std::max({rn, gn, bn});
    const double mn = std::min({rn, gn, bn});
    const double d = mx - mn;
    const double l = (mx + mn) / 2.0;
    double h = 0.0;
    double 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 {detail::round_half_up(h), detail::round_half_up(s * 100.0), detail::round_half_up(l * 100.0)};
}

/// {h,s,l} (h: 0–360, s/l: 0–100) → {r,g,b} (0–255).
inline std::array<double, 3> hsl_to_rgb(double h, double s, double l) {
    const double hn = detail::mod360(h);
    const double sn = detail::clamp01(s / 100.0);
    const double ln = detail::clamp01(l / 100.0);
    const double c = (1.0 - std::abs(2.0 * ln - 1.0)) * sn;
    const double x = c * (1.0 - std::abs(std::fmod(hn / 60.0, 2.0) - 1.0));
    const double m = ln - c / 2.0;
    double rr, gg, bb;
    if (hn < 60.0) {
        rr = c; gg = x; bb = 0.0;
    } else if (hn < 120.0) {
        rr = x; gg = c; bb = 0.0;
    } else if (hn < 180.0) {
        rr = 0.0; gg = c; bb = x;
    } else if (hn < 240.0) {
        rr = 0.0; gg = x; bb = c;
    } else if (hn < 300.0) {
        rr = x; gg = 0.0; bb = c;
    } else {
        rr = c; gg = 0.0; bb = x;
    }
    return {(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0};
}

/// "#hex" → {h,s,l}, or nullopt when the hex is invalid.
inline std::optional<std::array<double, 3>> hex_to_hsl(const std::string& hex) {
    const auto rgb = hex_to_rgb(hex);
    if (!rgb) return std::nullopt;
    return rgb_to_hsl((*rgb)[0], (*rgb)[1], (*rgb)[2]);
}

/// {h,s,l} → "#rrggbb".
inline std::string hsl_to_hex(double h, double s, double l) {
    const auto [r, g, b] = hsl_to_rgb(h, s, l);
    return rgb_to_hex(r, g, b);
}

// --- RGB ↔ HSV ---------------------------------------------------------------

/// {r,g,b} (0–255) → {h,s,v} with h: 0–360, s/v: 0–100.
inline std::array<double, 3> rgb_to_hsv(double r, double g, double b) {
    const double rn = detail::clamp01(r / 255.0);
    const double gn = detail::clamp01(g / 255.0);
    const double bn = detail::clamp01(b / 255.0);
    const double mx = std::max({rn, gn, bn});
    const double mn = std::min({rn, gn, bn});
    const double d = mx - mn;
    double 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;
    }
    const double s = (mx == 0.0) ? 0.0 : d / mx;
    return {detail::round_half_up(h), detail::round_half_up(s * 100.0), detail::round_half_up(mx * 100.0)};
}

/// {h,s,v} (h: 0–360, s/v: 0–100) → {r,g,b} (0–255).
inline std::array<double, 3> hsv_to_rgb(double h, double s, double v) {
    const double hn = detail::mod360(h);
    const double sn = detail::clamp01(s / 100.0);
    const double vn = detail::clamp01(v / 100.0);
    const double c = vn * sn;
    const double x = c * (1.0 - std::abs(std::fmod(hn / 60.0, 2.0) - 1.0));
    const double m = vn - c;
    double rr, gg, bb;
    if (hn < 60.0) {
        rr = c; gg = x; bb = 0.0;
    } else if (hn < 120.0) {
        rr = x; gg = c; bb = 0.0;
    } else if (hn < 180.0) {
        rr = 0.0; gg = c; bb = x;
    } else if (hn < 240.0) {
        rr = 0.0; gg = x; bb = c;
    } else if (hn < 300.0) {
        rr = x; gg = 0.0; bb = c;
    } else {
        rr = c; gg = 0.0; bb = x;
    }
    return {(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0};
}

/// {h,s,v} → "#rrggbb".
inline std::string hsv_to_hex(double h, double s, double v) {
    const auto [r, g, b] = hsv_to_rgb(h, s, v);
    return rgb_to_hex(r, g, b);
}

// --- RGB ↔ CMYK --------------------------------------------------------------

/// {r,g,b} (0–255) → {c,m,y,k} (0–100 each).
inline std::array<double, 4> rgb_to_cmyk(double r, double g, double b) {
    const double rn = detail::clamp01(r / 255.0);
    const double gn = detail::clamp01(g / 255.0);
    const double bn = detail::clamp01(b / 255.0);
    const double k = 1.0 - std::max({rn, gn, bn});
    if (k == 1.0) {
        return {0.0, 0.0, 0.0, 100.0};  // pure black — avoid divide-by-zero
    }
    const double c = (1.0 - rn - k) / (1.0 - k);
    const double m = (1.0 - gn - k) / (1.0 - k);
    const double y = (1.0 - bn - k) / (1.0 - k);
    return {
        detail::round_half_up(c * 100.0),
        detail::round_half_up(m * 100.0),
        detail::round_half_up(y * 100.0),
        detail::round_half_up(k * 100.0),
    };
}

/// {c,m,y,k} (0–100 each) → {r,g,b} (0–255).
inline std::array<double, 3> cmyk_to_rgb(double c, double m, double y, double k) {
    const double cn = detail::clamp01(c / 100.0);
    const double mn = detail::clamp01(m / 100.0);
    const double yn = detail::clamp01(y / 100.0);
    const double kn = detail::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".
inline std::string cmyk_to_hex(double c, double m, double y, double k) {
    const auto [r, g, b] = cmyk_to_rgb(c, m, y, k);
    return rgb_to_hex(r, g, b);
}

// --- 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 nullopt for invalid hex.
inline std::optional<ColorBundle> normalize_color(const std::string& hex) {
    const auto rgb = hex_to_rgb(hex);
    if (!rgb) return std::nullopt;
    const auto [r, g, b] = *rgb;
    return ColorBundle{
        rgb_to_hex(r, g, b),
        *rgb,
        rgb_to_hsl(r, g, b),
        rgb_to_hsv(r, g, b),
        rgb_to_cmyk(r, g, b),
    };
}

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

/// Linearize a single sRGB channel (0–255) per the WCAG 2.x relative-luminance
/// definition.
inline double srgb_channel(double c) {
    const double s = detail::clamp01(c / 255.0);
    if (s <= 0.03928) return s / 12.92;
    return std::pow((s + 0.055) / 1.055, 2.4);
}

/// WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or nullopt.
inline std::optional<double> relative_luminance(const std::string& hex) {
    const auto rgb = hex_to_rgb(hex);
    if (!rgb) return std::nullopt;
    return 0.2126 * srgb_channel((*rgb)[0])
         + 0.7152 * srgb_channel((*rgb)[1])
         + 0.0722 * srgb_channel((*rgb)[2]);
}

/// WCAG contrast ratio between two hexes (1–21), or nullopt if either is invalid.
inline std::optional<double> contrast_ratio(const std::string& a, const std::string& b) {
    const auto la = relative_luminance(a);
    const auto lb = relative_luminance(b);
    if (!la || !lb) return std::nullopt;
    const double hi = std::max(*la, *lb);
    const double lo = std::min(*la, *lb);
    return (hi + 0.05) / (lo + 0.05);
}

/// Pick black or white text for maximum legibility on hex, or nullopt if invalid.
inline std::optional<std::string_view> suggest_text_hex(const std::string& hex) {
    const auto l = relative_luminance(hex);
    if (!l) return std::nullopt;
    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.
inline constexpr std::pair<std::string_view, std::string_view> 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"},
};

/// Pre-resolved named color: name, hex, and parsed RGB.
struct ResolvedNamed {
    std::string_view name;
    std::string_view hex;
    std::array<double, 3> rgb;
};

/// Build the resolved named-color table once (function-local static init is
/// thread-safe since C++11) so nearest-match lookups never re-parse. Uses only
/// the standard library — the C++ analogue of Rust's OnceLock.
inline const std::vector<ResolvedNamed>& named_colors() {
    static const std::vector<ResolvedNamed> table = [] {
        std::vector<ResolvedNamed> resolved;
        resolved.reserve(std::size(NAMED_COLORS_RAW));
        for (const auto& [name, hex] : NAMED_COLORS_RAW) {
            // Curated hexes always parse — *hex_to_rgb cannot be null here.
            resolved.push_back({name, hex, *hex_to_rgb(std::string(hex))});
        }
        return resolved;
    }();
    return table;
}

/// Closest entry in the named-color table by squared RGB Euclidean distance.
/// Returns nullopt for invalid input. The table is a curated subset, so
/// "closest" is approximate, not a guarantee of identity.
inline std::optional<NamedColor> nearest_named_color(const std::string& hex) {
    const auto rgb = hex_to_rgb(hex);
    if (!rgb) return std::nullopt;
    const auto [r, g, b] = *rgb;
    const auto& table = named_colors();
    std::size_t best = 0;
    double best_d = std::numeric_limits<double>::infinity();
    for (std::size_t i = 0; i < table.size(); ++i) {
        const auto [nr, ng, nb] = table[i].rgb;
        const double dr = nr - r, dg = ng - g, db = nb - b;
        const double d = dr * dr + dg * dg + db * db;
        if (d < best_d) {
            best_d = d;
            best = i;
        }
    }
    return NamedColor{table[best].name, table[best].hex};
}

}  // namespace color_picker

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 →