color_tools
Convert colours between hex, RGB, HSL, HSV and CMYK, compute the WCAG 2.x relative luminance and contrast ratio of two colours with AA/AAA verdicts for normal text, large text and UI components, find the nearest CSS named colours (CIELAB delta E), and build palettes from a seed: complementary, triadic, analogous, split-complementary, tetradic, tints and shades. Reads hex, rgb(), hsl(), hsv(), cmyk() and the 148 CSS named colours.
Install
/tools install color_tools /tools install color_tools@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · color_tools 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool color_tools; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
operation | string | no | "convert" | one of "convert", "contrast", "nearest_named", "palette" |
color | string | object | no | #rgb, #rrggbb, #rrggbbaa, rgb(), hsl(), hsv(), cmyk(), a CSS name, or {r, g, b, a} | |
foreground | string | object | no | ||
background | string | object | no | ||
count | integer | no | 3 | ≥ 1; ≤ 20 |
scheme | string | no | "all" | one of "complementary", "triadic", "analogous", "split_complementary", "tetradic", "tints", "shades", "all" |
steps | integer | no | 5 | ≥ 1; ≤ 20 |
Input schema (JSON)
{
"type": "object",
"additionalProperties": false,
"properties": {
"operation": {
"type": "string",
"enum": [
"convert",
"contrast",
"nearest_named",
"palette"
],
"default": "convert"
},
"color": {
"type": [
"string",
"object"
],
"description": "#rgb, #rrggbb, #rrggbbaa, rgb(), hsl(), hsv(), cmyk(), a CSS name, or {r, g, b, a}"
},
"foreground": {
"type": [
"string",
"object"
]
},
"background": {
"type": [
"string",
"object"
]
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 3
},
"scheme": {
"type": "string",
"enum": [
"complementary",
"triadic",
"analogous",
"split_complementary",
"tetradic",
"tints",
"shades",
"all"
],
"default": "all"
},
"steps": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
}
}
}
Example
Input
{
"operation": "contrast",
"foreground": "#777777",
"background": "white"
}
Output (stdout)
{
"ratio": "4.48:1",
"wcag": {
"AA_normal_text": false,
"AA_large_text": true,
"AAA_normal_text": false
}
}
Files
The command the install card registers is python3 color_tools.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
color_tools.py · sha256 f1954bd5dcde0cc5f445796fe3fb892abc8307694a0152e85c60214f5f43dc26
#!/usr/bin/env python3
"""Colour conversions, WCAG 2.x contrast, nearest CSS named colour and palettes from a seed.
Input (JSON on stdin):
operation "convert" (default) | "contrast" | "nearest_named" | "palette"
color any of: "#rgb", "#rgba", "#rrggbb", "#rrggbbaa", "rgb(255 0 0)", "rgb(255, 0, 0, 0.5)",
"hsl(120, 50%, 40%)", "hsv(120, 50%, 40%)", "cmyk(0%, 100%, 100%, 0%)", a CSS named colour,
or {"r": 0-255, "g": 0-255, "b": 0-255}
foreground, background contrast (a translucent foreground is composited over the background)
count nearest_named: how many names, default 3 (1-20)
scheme palette: "complementary" | "triadic" | "analogous" | "split_complementary" |
"tetradic" | "tints" | "shades" | "all" (default)
steps palette tints/shades: number of colours, default 5 (1-20)
"""
import json
import math
import re
import sys
# CSS Color Module Level 4, section 6.1 "Named Colors" (W3C), all 148 keywords
NAMED = {
"aliceblue": "#f0f8ff", "antiquewhite": "#faebd7", "aqua": "#00ffff", "aquamarine": "#7fffd4",
"azure": "#f0ffff", "beige": "#f5f5dc", "bisque": "#ffe4c4", "black": "#000000",
"blanchedalmond": "#ffebcd", "blue": "#0000ff", "blueviolet": "#8a2be2", "brown": "#a52a2a",
"burlywood": "#deb887", "cadetblue": "#5f9ea0", "chartreuse": "#7fff00", "chocolate": "#d2691e",
"coral": "#ff7f50", "cornflowerblue": "#6495ed", "cornsilk": "#fff8dc", "crimson": "#dc143c",
"cyan": "#00ffff", "darkblue": "#00008b", "darkcyan": "#008b8b", "darkgoldenrod": "#b8860b",
"darkgray": "#a9a9a9", "darkgreen": "#006400", "darkgrey": "#a9a9a9", "darkkhaki": "#bdb76b",
"darkmagenta": "#8b008b", "darkolivegreen": "#556b2f", "darkorange": "#ff8c00",
"darkorchid": "#9932cc", "darkred": "#8b0000", "darksalmon": "#e9967a", "darkseagreen": "#8fbc8f",
"darkslateblue": "#483d8b", "darkslategray": "#2f4f4f", "darkslategrey": "#2f4f4f",
"darkturquoise": "#00ced1", "darkviolet": "#9400d3", "deeppink": "#ff1493",
"deepskyblue": "#00bfff", "dimgray": "#696969", "dimgrey": "#696969", "dodgerblue": "#1e90ff",
"firebrick": "#b22222", "floralwhite": "#fffaf0", "forestgreen": "#228b22", "fuchsia": "#ff00ff",
"gainsboro": "#dcdcdc", "ghostwhite": "#f8f8ff", "gold": "#ffd700", "goldenrod": "#daa520",
"gray": "#808080", "green": "#008000", "greenyellow": "#adff2f", "grey": "#808080",
"honeydew": "#f0fff0", "hotpink": "#ff69b4", "indianred": "#cd5c5c", "indigo": "#4b0082",
"ivory": "#fffff0", "khaki": "#f0e68c", "lavender": "#e6e6fa", "lavenderblush": "#fff0f5",
"lawngreen": "#7cfc00", "lemonchiffon": "#fffacd", "lightblue": "#add8e6", "lightcoral": "#f08080",
"lightcyan": "#e0ffff", "lightgoldenrodyellow": "#fafad2", "lightgray": "#d3d3d3",
"lightgreen": "#90ee90", "lightgrey": "#d3d3d3", "lightpink": "#ffb6c1", "lightsalmon": "#ffa07a",
"lightseagreen": "#20b2aa", "lightskyblue": "#87cefa", "lightslategray": "#778899",
"lightslategrey": "#778899", "lightsteelblue": "#b0c4de", "lightyellow": "#ffffe0",
"lime": "#00ff00", "limegreen": "#32cd32", "linen": "#faf0e6", "magenta": "#ff00ff",
"maroon": "#800000", "mediumaquamarine": "#66cdaa", "mediumblue": "#0000cd",
"mediumorchid": "#ba55d3", "mediumpurple": "#9370db", "mediumseagreen": "#3cb371",
"mediumslateblue": "#7b68ee", "mediumspringgreen": "#00fa9a", "mediumturquoise": "#48d1cc",
"mediumvioletred": "#c71585", "midnightblue": "#191970", "mintcream": "#f5fffa",
"mistyrose": "#ffe4e1", "moccasin": "#ffe4b5", "navajowhite": "#ffdead", "navy": "#000080",
"oldlace": "#fdf5e6", "olive": "#808000", "olivedrab": "#6b8e23", "orange": "#ffa500",
"orangered": "#ff4500", "orchid": "#da70d6", "palegoldenrod": "#eee8aa", "palegreen": "#98fb98",
"paleturquoise": "#afeeee", "palevioletred": "#db7093", "papayawhip": "#ffefd5",
"peachpuff": "#ffdab9", "peru": "#cd853f", "pink": "#ffc0cb", "plum": "#dda0dd",
"powderblue": "#b0e0e6", "purple": "#800080", "rebeccapurple": "#663399", "red": "#ff0000",
"rosybrown": "#bc8f8f", "royalblue": "#4169e1", "saddlebrown": "#8b4513", "salmon": "#fa8072",
"sandybrown": "#f4a460", "seagreen": "#2e8b57", "seashell": "#fff5ee", "sienna": "#a0522d",
"silver": "#c0c0c0", "skyblue": "#87ceeb", "slateblue": "#6a5acd", "slategray": "#708090",
"slategrey": "#708090", "snow": "#fffafa", "springgreen": "#00ff7f", "steelblue": "#4682b4",
"tan": "#d2b48c", "teal": "#008080", "thistle": "#d8bfd8", "tomato": "#ff6347",
"turquoise": "#40e0d0", "violet": "#ee82ee", "wheat": "#f5deb3", "white": "#ffffff",
"whitesmoke": "#f5f5f5", "yellow": "#ffff00", "yellowgreen": "#9acd32",
}
SCHEMES = {"complementary": [0, 180], "triadic": [0, 120, 240], "analogous": [-30, 0, 30],
"split_complementary": [0, 150, 210], "tetradic": [0, 90, 180, 270]}
class ColorError(Exception):
pass
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def channel(text, scale):
"""A number or percentage -> 0..scale."""
text = text.strip()
if text.endswith("%"):
value = float(text[:-1]) * scale / 100
else:
value = float(text)
if not math.isfinite(value):
raise ColorError("not a finite number")
return value
def hsl_to_rgb(h, s, l):
h = (h % 360) / 360
if s == 0:
return (l * 255,) * 3
def hue(p, q, t):
t %= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p
q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
return tuple(255 * hue(p, q, h + d) for d in (1 / 3, 0, -1 / 3))
def hsv_to_rgb(h, s, v):
h = (h % 360) / 60
c = v * s
x = c * (1 - abs(h % 2 - 1))
m = v - c
r, g, b = [(c, x, 0), (x, c, 0), (0, c, x), (0, x, c), (x, 0, c), (c, 0, x)][int(h) % 6]
return ((r + m) * 255, (g + m) * 255, (b + m) * 255)
def parse(value, field):
"""Return (r, g, b, alpha) with r, g, b floats 0-255 and alpha 0-1."""
if isinstance(value, dict):
if set(value) - {"r", "g", "b", "a"} or not {"r", "g", "b"} <= set(value):
raise ColorError(f"{field}: an object needs r, g, b (and optionally a)")
parts = []
for k in ("r", "g", "b"):
v = value[k]
if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 255:
raise ColorError(f"{field}.{k} must be a number from 0 to 255")
parts.append(float(v))
a = value.get("a", 1)
if isinstance(a, bool) or not isinstance(a, (int, float)) or not 0 <= a <= 1:
raise ColorError(f"{field}.a must be from 0 to 1")
return (*parts, float(a))
if not isinstance(value, str) or not value.strip() or len(value) > 100:
raise ColorError(f"{field} must be a colour string or an {{r, g, b}} object")
text = value.strip().lower()
if text in NAMED:
text = NAMED[text]
if text == "transparent":
return (0.0, 0.0, 0.0, 0.0)
m = re.fullmatch(r"#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})", text)
if m:
digits = m.group(1)
if len(digits) in (3, 4):
digits = "".join(ch * 2 for ch in digits)
nums = [int(digits[i:i + 2], 16) for i in range(0, len(digits), 2)]
return (float(nums[0]), float(nums[1]), float(nums[2]), nums[3] / 255 if len(nums) == 4 else 1.0)
m = re.fullmatch(r"(rgba?|hsla?|hsv|hsb|cmyk)\((.*)\)", text)
if not m:
raise ColorError(f"{field}: '{value}' is not a colour this tool reads (hex, rgb(), hsl(), hsv(), cmyk() or a CSS name)")
kind, body = m.group(1), m.group(2)
alpha = 1.0
if "/" in body:
body, a = body.split("/", 1)
alpha = channel(a, 1)
parts = [p for p in re.split(r"[\s,]+", body.strip()) if p]
try:
if kind in ("rgb", "rgba"):
if len(parts) == 4:
alpha = channel(parts.pop(), 1)
if len(parts) != 3:
raise ColorError(f"{field}: rgb() takes three channels")
r, g, b = (channel(p, 255) for p in parts)
elif kind in ("hsl", "hsla", "hsv", "hsb"):
if len(parts) == 4:
alpha = channel(parts.pop(), 1)
if len(parts) != 3:
raise ColorError(f"{field}: {kind}() takes hue, saturation and lightness/value")
h = float(parts[0].replace("deg", ""))
s, l = channel(parts[1], 1), channel(parts[2], 1)
if not (0 <= s <= 1 and 0 <= l <= 1):
raise ColorError(f"{field}: saturation and lightness/value must be 0-100%")
r, g, b = hsl_to_rgb(h, s, l) if kind.startswith("hsl") else hsv_to_rgb(h, s, l)
else:
if len(parts) != 4:
raise ColorError(f"{field}: cmyk() takes four channels")
c, mm, y, k = (channel(p, 1) for p in parts)
if not all(0 <= x <= 1 for x in (c, mm, y, k)):
raise ColorError(f"{field}: cmyk channels must be 0-100%")
r, g, b = (255 * (1 - x) * (1 - k) for x in (c, mm, y))
except ValueError:
raise ColorError(f"{field}: '{value}' has a channel that is not a number")
if not all(0 <= x <= 255.0000001 for x in (r, g, b)) or not 0 <= alpha <= 1:
raise ColorError(f"{field}: channels out of range")
return (min(r, 255.0), min(g, 255.0), min(b, 255.0), alpha)
def rgb8(rgb):
return tuple(int(math.floor(x + 0.5 + 1e-9)) for x in rgb[:3])
def to_hex(rgb, alpha=1.0):
r, g, b = rgb8(rgb)
text = f"#{r:02x}{g:02x}{b:02x}"
return text + (f"{round(alpha * 255):02x}" if alpha < 1 else "")
def to_hsl(rgb):
r, g, b = (x / 255 for x in rgb[:3])
hi, lo = max(r, g, b), min(r, g, b)
l = (hi + lo) / 2
d = hi - lo
if d == 0:
return 0.0, 0.0, l
s = d / (1 - abs(2 * l - 1))
return hue_of(r, g, b, hi, d), s, l
def hue_of(r, g, b, hi, d):
if hi == r:
h = ((g - b) / d) % 6
elif hi == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
return (h * 60) % 360
def to_hsv(rgb):
r, g, b = (x / 255 for x in rgb[:3])
hi, lo = max(r, g, b), min(r, g, b)
d = hi - lo
return (hue_of(r, g, b, hi, d) if d else 0.0), (d / hi if hi else 0.0), hi
def to_cmyk(rgb):
r, g, b = (x / 255 for x in rgb[:3])
k = 1 - max(r, g, b)
if k >= 1:
return 0.0, 0.0, 0.0, 1.0
return tuple((1 - x - k) / (1 - k) for x in (r, g, b)) + (k,)
def linear(c):
"""sRGB transfer function inverse; WCAG 2.x relative-luminance definition (threshold 0.04045)."""
c /= 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(rgb):
r, g, b = (linear(x) for x in rgb8(rgb))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def lab(rgb):
r, g, b = (linear(x) for x in rgb[:3])
x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
y = 0.2126 * r + 0.7152 * g + 0.0722 * b
z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
f = lambda t: t ** (1 / 3) if t > (6 / 29) ** 3 else t / (3 * (6 / 29) ** 2) + 4 / 29
fx, fy, fz = f(x), f(y), f(z)
return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
def nearest(rgb, count):
target = lab(rgb8(rgb))
scored = []
for name, hx in NAMED.items():
other = parse(hx, "name")
scored.append((math.dist(target, lab(other)), name, hx))
scored.sort()
return [{"name": n, "hex": h, "delta_e76": round(d, 2)} for d, n, h in scored[:count]]
def describe(rgb, alpha=1.0):
h, s, l = to_hsl(rgb)
hv, sv, v = to_hsv(rgb)
c, m, y, k = to_cmyk(rgb)
r, g, b = rgb8(rgb)
hx = to_hex(rgb)
out = {"hex": to_hex(rgb, alpha), "rgb": {"r": r, "g": g, "b": b}, "css_rgb": f"rgb({r} {g} {b}" + (f" / {round(alpha, 3)})" if alpha < 1 else ")"),
"hsl": {"h": round(h, 1), "s": round(s * 100, 1), "l": round(l * 100, 1)},
"css_hsl": f"hsl({round(h, 1):g} {round(s * 100, 1):g}% {round(l * 100, 1):g}%)",
"hsv": {"h": round(hv, 1), "s": round(sv * 100, 1), "v": round(v * 100, 1)},
"cmyk": {"c": round(c * 100, 1), "m": round(m * 100, 1), "y": round(y * 100, 1), "k": round(k * 100, 1)},
"relative_luminance": round(luminance(rgb), 6)}
if alpha < 1:
out["alpha"] = round(alpha, 4)
names = [n for n, v in NAMED.items() if v == hx]
out["css_names"] = names
return out
def contrast(fg, bg):
if bg[3] < 1:
raise ColorError("background must be opaque")
a = fg[3]
mixed = tuple(fg[i] * a + bg[i] * (1 - a) for i in range(3))
l1, l2 = luminance(mixed), luminance(bg)
ratio = (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
verdict = lambda need: ratio >= need
out = {"foreground": to_hex(fg, fg[3]), "background": to_hex(bg), "ratio": f"{math.floor(ratio * 100 + 0.5) / 100:.2f}:1",
"ratio_value": round(ratio, 4), "foreground_luminance": round(l1, 6), "background_luminance": round(l2, 6),
"wcag": {"AA_normal_text": verdict(4.5), "AA_large_text": verdict(3), "AAA_normal_text": verdict(7),
"AAA_large_text": verdict(4.5), "non_text_UI": verdict(3)},
"rule": "WCAG 2.x: (L1 + 0.05) / (L2 + 0.05); AA 4.5:1 (large text 3:1), AAA 7:1 (large 4.5:1), UI components 3:1; verdicts use the unrounded ratio"}
if a < 1:
out["composited_foreground"] = to_hex(mixed)
return out
def palette(rgb, scheme, steps):
h, s, l = to_hsl(rgb)
out = {}
names = list(SCHEMES) + ["tints", "shades"] if scheme == "all" else [scheme]
for name in names:
if name in SCHEMES:
out[name] = [to_hex(hsl_to_rgb(h + d, s, l)) for d in SCHEMES[name]]
elif name == "tints":
out[name] = [to_hex(tuple(x + (255 - x) * i / (steps + 1) for x in rgb[:3])) for i in range(1, steps + 1)]
else:
out[name] = [to_hex(tuple(x * (1 - i / (steps + 1)) for x in rgb[:3])) for i in range(1, steps + 1)]
return out
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = set(request) - {"operation", "color", "foreground", "background", "count", "scheme", "steps"}
if extra:
fail(f"unknown field(s): {', '.join(sorted(extra))}")
operation = request.get("operation", "convert")
try:
if operation == "contrast":
if "foreground" not in request or "background" not in request:
fail("contrast needs foreground and background")
out = contrast(parse(request["foreground"], "foreground"), parse(request["background"], "background"))
elif operation in ("convert", "nearest_named", "palette"):
if "color" not in request:
fail(f"{operation} needs color")
rgb = parse(request["color"], "color")
if operation == "convert":
out = describe(rgb, rgb[3])
out["nearest_named"] = nearest(rgb, 1)[0]
elif operation == "nearest_named":
count = request.get("count", 3)
if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= 20:
fail("count must be an integer from 1 to 20")
out = {"color": to_hex(rgb), "nearest": nearest(rgb, count),
"metric": "CIE76 delta E in CIELAB (D65), from sRGB"}
else:
scheme = request.get("scheme", "all")
if scheme not in list(SCHEMES) + ["tints", "shades", "all"]:
fail("scheme must be complementary, triadic, analogous, split_complementary, tetradic, tints, shades or all")
steps = request.get("steps", 5)
if isinstance(steps, bool) or not isinstance(steps, int) or not 1 <= steps <= 20:
fail("steps must be an integer from 1 to 20")
out = {"seed": to_hex(rgb), "palette": palette(rgb, scheme, steps),
"method": "hue rotations in HSL keeping saturation and lightness; tints mix with white and shades with black in equal steps"}
else:
fail("operation must be convert, contrast, nearest_named or palette")
except ColorError as error:
fail(str(error))
out = {"operation": operation, **out}
print(json.dumps(out))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # never a traceback
fail(f"{type(error).__name__}: {error}")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/color_tools/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/color_tools/1.0.0/color_tools.py