Color Picker & Converter — Zig 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 Zig 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: Zig 0.13 (std only — no ecosystem packages; the Rust original's
// std-only rule maps 1:1).
//
// 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 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. Functions that return a hex string take
// an allocator and return an owned slice the caller must free.
//
// License: display source — part of CosmoDev's polyglot tool pages.
const std = @import("std");
/// A color resolved into every supported space, all mutually consistent.
pub const ColorBundle = struct {
hex: []const u8, // "#rrggbb" (lowercase) — the canonical handle (caller-owned)
rgb: [3]f64, // 0–255 each
hsl: [3]f64, // h: 0–360, s/l: 0–100
hsv: [3]f64, // h: 0–360, s/v: 0–100
cmyk: [4]f64, // 0–100 each
};
/// A named CSS color with its hex.
pub const NamedColor = struct {
name: []const u8,
hex: []const u8,
};
// --- Internal clamp helpers ----------------------------------------------------
// Every public function is total: invalid hex → null; out-of-range numbers are
// clamped into their valid interval. @min/@max propagate NaN, so NaN is handled
// explicitly before they ever see it.
fn clamp(n: f64, lo: f64, hi: f64) f64 {
if (std.math.isNan(n)) return lo;
if (n == std.math.inf(f64)) return hi;
if (n == -std.math.inf(f64)) return lo;
return @min(hi, @max(lo, n));
}
fn clamp01(n: f64) f64 {
return clamp(n, 0.0, 1.0);
}
/// Round half away from zero — mirrors JS Math.round for the non-negative
/// values this file rounds. The explicit @floor(n + 0.5) form documents the
/// parity with the TypeScript source.
fn roundHalfUp(n: f64) f64 {
return @floor(n + 0.5);
}
/// Normalize any hue (negative, >360, or NaN) into [0, 360), matching
/// JS ((Number(h) || 0) % 360 + 360) % 360. @rem matches the JS % sign rule
/// (sign of the dividend); @mod always yields a non-negative remainder here.
fn mod360(h: f64) f64 {
const hh: f64 = if (std.math.isNan(h)) 0.0 else h;
return @mod(@rem(hh, 360.0) + 360.0, 360.0);
}
// --- HEX ↔ RGB -----------------------------------------------------------------
fn isHexDigit(c: u8) bool {
return switch (c) {
'0'...'9', 'a'...'f', 'A'...'F' => true,
else => false,
};
}
/// Reports whether every byte of `s` is an ASCII hex digit. Used in lieu of a
/// regex to keep this snippet dependency-free.
fn isHexDigits(s: []const u8) bool {
if (s.len == 0) return false;
for (s) |c| {
if (!isHexDigit(c)) return false;
}
return true;
}
/// Value of one validated ASCII hex digit.
fn hexVal(c: u8) u8 {
return switch (c) {
'0'...'9' => c - '0',
'a'...'f' => c - 'a' + 10,
'A'...'F' => c - 'A' + 10,
else => unreachable,
};
}
/// Parse exactly two validated ASCII hex digits into 0–255.
fn parseHexByte(s: []const u8) f64 {
const v: u16 = (@as(u16, hexVal(s[0])) << 4) | hexVal(s[1]);
return @as(f64, @floatFromInt(v));
}
/// Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into {r,g,b}, or
/// null for anything that is not a 3- or 6-digit hex color.
pub fn hexToRgb(value: []const u8) ?[3]f64 {
// Trim ASCII whitespace from both ends, mirroring Python str.strip().
var h: []const u8 = std.mem.trim(u8, value, " \t\n\r\x0b\x0c");
// TS strips exactly one leading '#'.
if (h.len > 0 and h[0] == '#') h = h[1..];
// Expand shorthand (#rgb → #rrggbb) only when it is exactly three hex digits.
var expanded: [6]u8 = undefined;
if (h.len == 3 and isHexDigits(h)) {
expanded[0] = h[0];
expanded[1] = h[0];
expanded[2] = h[1];
expanded[3] = h[1];
expanded[4] = h[2];
expanded[5] = h[2];
h = expanded[0..];
}
if (h.len != 6 or !isHexDigits(h)) return null;
return .{
parseHexByte(h[0..2]),
parseHexByte(h[2..4]),
parseHexByte(h[4..6]),
};
}
/// The clamped, rounded value always lands in 0–255 — exactly what a u8 holds.
fn toByte(n: f64) u8 {
return @intFromFloat(roundHalfUp(n));
}
/// [r,g,b] (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded). The
/// returned slice is owned by the caller.
pub fn rgbToHex(allocator: std.mem.Allocator, r: f64, g: f64, b: f64) ![]u8 {
return std.fmt.allocPrint(allocator, "#{x:0>2}{x:0>2}{x:0>2}", .{
toByte(clamp(r, 0.0, 255.0)),
toByte(clamp(g, 0.0, 255.0)),
toByte(clamp(b, 0.0, 255.0)),
});
}
// --- RGB ↔ HSL -----------------------------------------------------------------
/// [r,g,b] (0–255) → [h,s,l] with h: 0–360, s/l: 0–100.
pub fn rgbToHsl(r: f64, g: f64, b: f64) [3]f64 {
const rn = clamp01(r / 255.0);
const gn = clamp01(g / 255.0);
const bn = clamp01(b / 255.0);
const mx = @max(rn, @max(gn, bn));
const mn = @min(rn, @min(gn, bn));
const d = mx - mn;
const l = (mx + mn) / 2.0;
var h: f64 = 0.0;
var s: f64 = 0.0;
if (d != 0.0) {
// Saturation formula branches on which half of the lightness axis we sit on.
s = if (l > 0.5) d / (2.0 - mx - mn) else d / (mx + mn);
if (mx == rn) {
h = (gn - bn) / d + (if (gn < bn) @as(f64, 6.0) else 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).
pub fn hslToRgb(h: f64, s: f64, l: f64) [3]f64 {
const hn = mod360(h);
const sn = clamp01(s / 100.0);
const ln = clamp01(l / 100.0);
const c = (1.0 - @abs(2.0 * ln - 1.0)) * sn;
const x = c * (1.0 - @abs(@rem(hn / 60.0, 2.0) - 1.0));
const m = ln - c / 2.0;
return sectorToRgb(hn, c, x, m);
}
/// "#hex" → [h,s,l], or null when the hex is invalid.
pub fn hexToHsl(value: []const u8) ?[3]f64 {
const rgb = hexToRgb(value) orelse return null;
return rgbToHsl(rgb[0], rgb[1], rgb[2]);
}
/// [h,s,l] → "#rrggbb". The returned slice is owned by the caller.
pub fn hslToHex(allocator: std.mem.Allocator, h: f64, s: f64, l: f64) ![]u8 {
const rgb = hslToRgb(h, s, l);
return rgbToHex(allocator, rgb[0], rgb[1], rgb[2]);
}
// --- RGB ↔ HSV -----------------------------------------------------------------
/// [r,g,b] (0–255) → [h,s,v] with h: 0–360, s/v: 0–100.
pub fn rgbToHsv(r: f64, g: f64, b: f64) [3]f64 {
const rn = clamp01(r / 255.0);
const gn = clamp01(g / 255.0);
const bn = clamp01(b / 255.0);
const mx = @max(rn, @max(gn, bn));
const mn = @min(rn, @min(gn, bn));
const d = mx - mn;
var h: f64 = 0.0;
if (d != 0.0) {
if (mx == rn) {
h = (gn - bn) / d + (if (gn < bn) @as(f64, 6.0) else 0.0);
} else if (mx == gn) {
h = (bn - rn) / d + 2.0;
} else {
h = (rn - gn) / d + 4.0;
}
h *= 60.0;
}
const s: f64 = if (mx == 0.0) 0.0 else 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).
pub fn hsvToRgb(h: f64, s: f64, v: f64) [3]f64 {
const hn = mod360(h);
const sn = clamp01(s / 100.0);
const vn = clamp01(v / 100.0);
const c = vn * sn;
const x = c * (1.0 - @abs(@rem(hn / 60.0, 2.0) - 1.0));
const m = vn - c;
return sectorToRgb(hn, c, x, m);
}
/// [h,s,v] → "#rrggbb". The returned slice is owned by the caller.
pub fn hsvToHex(allocator: std.mem.Allocator, h: f64, s: f64, v: f64) ![]u8 {
const rgb = hsvToRgb(h, s, v);
return rgbToHex(allocator, 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.
fn sectorToRgb(hn: f64, c: f64, x: f64, m: f64) [3]f64 {
var rr: f64 = undefined;
var gg: f64 = undefined;
var bb: f64 = undefined;
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 };
}
// --- RGB ↔ CMYK ----------------------------------------------------------------
/// [r,g,b] (0–255) → [c,m,y,k] (0–100 each).
pub fn rgbToCmyk(r: f64, g: f64, b: f64) [4]f64 {
const rn = clamp01(r / 255.0);
const gn = clamp01(g / 255.0);
const bn = clamp01(b / 255.0);
const k = 1.0 - @max(rn, @max(gn, bn));
if (k == 1.0) {
return .{ 0.0, 0.0, 0.0, 100.0 }; // pure black — avoid divide-by-zero
}
const c = (1.0 - rn - k) / (1.0 - k);
const m = (1.0 - gn - k) / (1.0 - k);
const 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).
pub fn cmykToRgb(c: f64, m: f64, y: f64, k: f64) [3]f64 {
const cn = clamp01(c / 100.0);
const mn = clamp01(m / 100.0);
const yn = clamp01(y / 100.0);
const 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". The returned slice is owned by the caller.
pub fn cmykToHex(allocator: std.mem.Allocator, c: f64, m: f64, y: f64, k: f64) ![]u8 {
const rgb = cmykToRgb(c, m, y, k);
return rgbToHex(allocator, rgb[0], rgb[1], rgb[2]);
}
// --- 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 null for invalid hex; on
/// success `bundle.hex` is owned by the caller.
pub fn normalizeColor(allocator: std.mem.Allocator, value: []const u8) !?ColorBundle {
const rgb = hexToRgb(value) orelse return null;
const r = rgb[0];
const g = rgb[1];
const b = rgb[2];
return ColorBundle{
.hex = try rgbToHex(allocator, r, g, b),
.rgb = rgb,
.hsl = rgbToHsl(r, g, b),
.hsv = rgbToHsv(r, g, b),
.cmyk = rgbToCmyk(r, g, b),
};
}
// --- WCAG luminance, contrast & text suggestion --------------------------------
/// Linearize a single sRGB channel (0–255) per the WCAG 2.x relative-luminance
/// definition.
fn srgbChannel(c: f64) f64 {
const s = clamp01(c / 255.0);
if (s <= 0.03928) return s / 12.92;
return std.math.pow(f64, (s + 0.055) / 1.055, 2.4);
}
/// WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or null.
pub fn relativeLuminance(value: []const u8) ?f64 {
const rgb = hexToRgb(value) orelse return null;
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 null if either is invalid.
pub fn contrastRatio(a: []const u8, b: []const u8) ?f64 {
const la = relativeLuminance(a) orelse return null;
const lb = relativeLuminance(b) orelse return null;
const hi = @max(la, lb);
const lo = @min(la, lb);
return (hi + 0.05) / (lo + 0.05);
}
/// Pick black or white text for maximum legibility on `value`, or null if
/// invalid.
pub fn suggestTextHex(value: []const u8) ?[]const u8 {
const l = relativeLuminance(value) orelse return null;
return if (l > 0.179) "#000000" else "#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.
const NAMED_COLORS_RAW = [_][2][]const u8{
.{ "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.
const ResolvedNamed = struct {
name: []const u8,
hex: []const u8,
rgb: [3]f64,
};
/// Resolve every named color's RGB at comptime — the Zig analogue of the TS
/// source's eager module-init resolution: nearest-match lookups never parse at
/// runtime. If a curated hex ever fails to parse, `unreachable` trips at
/// compile time, so the table cannot silently rot.
const NAMED_COLORS: [NAMED_COLORS_RAW.len]ResolvedNamed = blk: {
var resolved: [NAMED_COLORS_RAW.len]ResolvedNamed = undefined;
for (NAMED_COLORS_RAW, 0..) |entry, i| {
resolved[i] = .{
.name = entry[0],
.hex = entry[1],
.rgb = hexToRgb(entry[1]) orelse unreachable, // curated hexes always parse
};
}
break :blk resolved;
};
/// Closest entry in the named-color table by squared RGB Euclidean distance.
/// Returns null for invalid input. The table is a curated subset, so "closest"
/// is approximate, not a guarantee of identity.
pub fn nearestNamedColor(value: []const u8) ?NamedColor {
const rgb = hexToRgb(value) orelse return null;
const r = rgb[0];
const g = rgb[1];
const b = rgb[2];
var best: usize = 0;
var best_d: f64 = std.math.inf(f64);
for (NAMED_COLORS, 0..) |entry, i| {
const dr = entry.rgb[0] - r;
const dg = entry.rgb[1] - g;
const db = entry.rgb[2] - b;
const d = dr * dr + dg * dg + db * db;
if (d < best_d) {
best_d = d;
best = i;
}
}
return NamedColor{ .name = NAMED_COLORS[best].name, .hex = NAMED_COLORS[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 →