Color Palette Generator — Python source
Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""palette-generator — polyglot showcase port (Python).
Pure color-theory helpers for the Color Palette Generator. No I/O, no external
dependencies — standard library only. All inputs are clamped; the functions
never raise on bad input (they fall back to black).
Ported from src/lib/colorPalette.ts (TypeScript, the canonical implementation).
This is display source — part of CosmoDev's polyglot tool pages
(dev.cosmolabs.org), where each tool's pure logic is shown side-by-side in six
languages.
"""
from __future__ import annotations
import math
import re
from typing import Literal
# Scheme identifiers selecting which color-harmony rule generate_palette applies.
# This is the runtime analog of the TypeScript ``Scheme`` string-union type.
Scheme = Literal[
"complement",
"split-complement",
"analogous",
"triadic",
"tetradic",
"monochromatic",
]
_HEX3 = re.compile(r"^[0-9a-fA-F]{3}$")
_HEX6 = re.compile(r"^[0-9a-fA-F]{6}$")
def hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
"""Parse any reasonable hex string (#rgb / #rrggbb, with or without a leading #)
into an (r, g, b) byte triple. Unparseable input falls back to black."""
h = re.sub(r"^#", "", str(hex_str).strip())
if _HEX3.match(h):
# Expand CSS shorthand: each digit doubles (#abc -> #aabbcc).
h = "".join(c + c for c in h)
if not _HEX6.match(h):
return (0, 0, 0)
return (
int(h[0:2], 16),
int(h[2:4], 16),
int(h[4:6], 16),
)
def _round_half_up(n: float) -> int:
"""Round half up (toward +Inf) to match TypeScript's ``Math.round`` on the
non-negative color values these helpers produce. Python's built-in
``round()`` uses banker's rounding (half to even), which would diverge from
the canonical TypeScript output at exact .5 boundaries."""
return math.floor(n + 0.5)
def _clamp_byte(n: float) -> int:
"""Round, then clamp to a byte in [0, 255]."""
return max(0, min(255, _round_half_up(n)))
def _rgb_to_hex(r: float, g: float, b: float) -> str:
"""Pack three float channels into a lowercase #rrggbb string."""
return f"#{_clamp_byte(r):02x}{_clamp_byte(g):02x}{_clamp_byte(b):02x}"
def hex_to_hsl(hex_str: str) -> tuple[float, float, float]:
"""Convert hex → HSL. h ∈ [0, 360), s/l ∈ [0, 100]. Achromatic colors (gray,
white, black — no dominant hue) collapse to h = 0, s = 0."""
r8, g8, b8 = hex_to_rgb(hex_str)
r = r8 / 255
g = g8 / 255
b = b8 / 255
mx = max(r, g, b)
mn = min(r, g, b)
l = (mx + mn) / 2
h = 0.0
s = 0.0
if mx != mn:
d = mx - mn
s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
# Which channel is max determines the hue sextant.
if mx == r:
h = (g - b) / d + (6 if g < b else 0)
elif mx == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
h /= 6
return (h * 360, s * 100, l * 100)
def hsl_to_hex(h: float, s: float, l: float) -> str:
"""Convert HSL → hex. h wraps modulo 360 (so h + 150 etc. stay in range);
s and l clamp to [0, 100]."""
# Python's % is a true modulo (always non-negative for a positive divisor),
# so the double-mod wrap yields the same [0, 360) result as the TS source
# even though TS's % is a remainder.
H = ((h % 360) + 360) % 360
S = max(0.0, min(100.0, s)) / 100
L = max(0.0, min(100.0, l)) / 100
c = (1 - abs(2 * L - 1)) * S
x = c * (1 - abs((H / 60) % 2 - 1))
m = L - c / 2
if H < 60:
r, g, b = c, x, 0.0
elif H < 120:
r, g, b = x, c, 0.0
elif H < 180:
r, g, b = 0.0, c, x
elif H < 240:
r, g, b = 0.0, x, c
elif H < 300:
r, g, b = x, 0.0, c
else:
r, g, b = c, 0.0, x
return _rgb_to_hex((r + m) * 255, (g + m) * 255, (b + m) * 255)
def generate_palette(base_hex: str, scheme: Scheme, count: int = 5) -> list[str]:
"""Generate a harmonious palette from a base color.
Counts: complement = 2, split-complement = 3, analogous = 3, triadic = 3,
tetradic = 4. ``count`` is honored by monochromatic (default 5): the base
hue and saturation are held while lightness spreads across ``count`` steps.
"""
h, s, l = hex_to_hsl(base_hex)
base = hsl_to_hex(h, s, l)
def rot(deg: float) -> str:
# Rotate the hue by deg degrees, holding saturation and lightness.
return hsl_to_hex(h + deg, s, l)
if scheme == "complement":
return [base, rot(180)]
if scheme == "split-complement":
return [base, rot(150), rot(210)]
if scheme == "analogous":
return [rot(-30), base, rot(30)]
if scheme == "triadic":
return [base, rot(120), rot(240)]
if scheme == "tetradic":
return [base, rot(90), rot(180), rot(270)]
if scheme == "monochromatic":
n = max(1, math.floor(count))
# Pin the lightness window to [10, 90] so swatches never fully wash out
# or go black, regardless of the base color's own lightness.
lo = max(10, l - 32)
hi = min(90, l + 32)
out: list[str] = []
for i in range(n):
ll = l if n == 1 else lo + (hi - lo) * i / (n - 1)
out.append(hsl_to_hex(h, s, ll))
return out
return [base]
def shades(base_hex: str, n: int) -> list[str]:
"""n shades — the base color mixed progressively toward black (RGB lerp).
i runs 1..n so the base itself is never returned, only intermediate steps."""
r, g, b = hex_to_rgb(base_hex)
steps = max(1, math.floor(n))
out: list[str] = []
for i in range(1, steps + 1):
f = i / (steps + 1)
out.append(_rgb_to_hex(r * (1 - f), g * (1 - f), b * (1 - f)))
return out
def tints(base_hex: str, n: int) -> list[str]:
"""n tints — the base color mixed progressively toward white (RGB lerp).
i runs 1..n so the base itself is never returned."""
r, g, b = hex_to_rgb(base_hex)
steps = max(1, math.floor(n))
out: list[str] = []
for i in range(1, steps + 1):
f = i / (steps + 1)
out.append(
_rgb_to_hex(
r + (255 - r) * f,
g + (255 - g) * f,
b + (255 - b) * f,
)
)
return out
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 →