-- Helper to select an accessible color relative to a base (background) color
local I = {} -- for internal names
-- Given a spec for a baseline color and a foreground color _slice_, return a
-- specific foreground color in the slice that is sufficiently high-contrast
-- as per WCAG.
-- We specify colors in perceptually uniform Oklch space. Oklch represents
-- colors as combinations of 3 dimensions:
-- L is perceptual lightness
-- C is chromatic intensity
-- h is hue or color
-- bg_str is a color string made from these dimensions.
-- I don't care too much about precision compared to accessibility, so I'm
-- going to quantize these dimensions in reverse order.
-- h can take one of 8 values: red orange yellow green cyan blue purple magenta
-- C is on an 8 point scale from 0 to 7
-- L is on an 8 point scale from 0 to 7
-- Example color spec: 'red:8:7'
--
-- One special case is greyscale, which doesn't get a chromatic intensity.
-- Black is 'grey:0' and white is 'grey:7'.
--
-- fg_str is a slice that leaves some room for selection; either just 'h' or 'h:C'
--
-- The return value is a 3-vector containing sRGB components that you can pass
-- into love.graphics.setColor.
function select_rgb(bg_str, fg_str)
local bg = I.parse_color(bg_str)
assert(bg.L, 'background color must contain a lightness (index 3)')
local fg = I.parse_color(fg_str)
if fg.L then return I.convert_rgb(fg) end -- no wiggle room; return the color that came in
local result = I.select_lightness(bg, fg)
if result then return result end
result = I.select_chroma_lightness(bg, fg)
if result then return result end
result = I.select_hue_chroma_lightness(bg, fg)
if result then return result end
error('could not find a color; something is wrong')
end
function color_to_rgb(color_str)
return I.convert_rgb(I.parse_color(color_str))
end
function I.select_hue_chroma_lightness(bg, fg)
local dhues = {5, -5, 10, -10, 15, -15}
for _, dhue in ipairs(dhues) do
local result = I.select_chroma_lightness(bg, {h=fg.h+dhue, C=fg.C})
if result then return result end
end
end
function I.select_chroma_lightness(bg, fg)
local dchromas = {0.05, -0.05, 0.1, -0.1, 0.15, -0.15, 0.2, -0.2, 0.25, -0.25}
local c = fg.C
for _, dchroma in ipairs(dchromas) do
if 0 <= fg.C+dchroma and fg.C+dchroma <= 0.5 then
local result = I.select_lightness(bg, {h=fg.h, C=fg.C+dchroma})
if result then return result end
end
end
end
function I.select_lightness(bg, fg)
local target_contrast = 7
local bgR, bgG, bgB = I.oklch_to_sRGB(bg.L, bg.C, bg.h)
local ls
if bg.L < 0.5 then
ls = {0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0}
else
ls = {0.45, 0.4, 0.35, 0.3, 0.25, 0.2, 0.15, 0.1, 0.05, 0}
end
for _, fgL in ipairs(ls) do
local fgR, fgG, fgB = I.oklch_to_sRGB(fgL, fg.C, fg.h)
if I.in_gamut(fgR, fgG, fgB) then
local contrast = I.contrast_ratio(bgR, bgG, bgB, fgR, fgG, fgB)
if contrast >= target_contrast then
return {fgR, fgG, fgB}
end
end
end
end
function I.convert_rgb(c)
local r, g, b = I.oklch_to_sRGB(c.L, c.C, c.h)
return {r, g, b}
end
I.HUE_MAP = {
red = 30,
orange = 60,
yellow = 90,
green = 150,
cyan = 200,
blue = 250,
purple = 300,
magenta = 330
}
function I.parse_color(color_str)
local parts = {}
for part in color_str:gmatch('[^:]+') do
table.insert(parts, part)
end
local result
local h = parts[1]:lower()
if h == 'grey' or h == 'gray' then
result = {
h = --[[can be anything]] 0,
C = 0,
L = tonumber(parts[2]),
}
else
result = {
h = I.HUE_MAP[h],
C = tonumber(parts[2]),
L = tonumber(parts[3]),
}
assert(result.h, 'unknown hue '..parts[1])
if result.C then
result.C = result.C/7 * 0.3
else
result.C = 0.3 -- max chroma
end
end
if result.L then
result.L = result.L/7
end
return result
end
-- https://bottosson.github.io/posts/oklab
function I.oklch_to_sRGB(L, C, h)
local hRad = math.rad(h)
-- Oklch -> Oklab
local a = C * math.cos(hRad)
local b = C * math.sin(hRad)
-- Oklab -> lms
local l = L + 0.3963377774 * a + 0.2158037573 * b
local m = L - 0.1055613458 * a - 0.0638541728 * b
local s = L - 0.0894841775 * a - 1.2914855480 * b
-- non-linearity
l = l^3
m = m^3
s = s^3
-- lms -> linear
local r_l = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
local g_l = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
local b_l = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
-- linear -> sRGB
local r = I.clamp(I.linear_to_sRGB(r_l), 0, 1)
local g = I.clamp(I.linear_to_sRGB(g_l), 0, 1)
local b = I.clamp(I.linear_to_sRGB(b_l), 0, 1)
return r, g, b
end
-- https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio
function I.contrast_ratio(r1, g1, b1, r2, g2, b2)
local l1 = I.luminance(r1, g1, b1)
local l2 = I.luminance(r2, g2, b2)
if l1 < l2 then
l1, l2 = l2, l1
end
return (l1+0.05) / (l2+0.05)
end
-- https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-relative-luminance
function I.luminance(r, g, b)
local r_l = I.sRGB_to_linear(r)
local g_l = I.sRGB_to_linear(g)
local b_l = I.sRGB_to_linear(b)
return 0.2126*r_l + 0.7152*g_l + 0.0722*b_l
end
-- https://stackoverflow.com/questions/12524623/what-are-the-practical-differences-when-working-with-colors-in-a-linear-vs-a-no
function I.sRGB_to_linear(val)
if val <= 0.04045 then
return val / 12.92
else
return ((val + 0.055) / 1.055) ^ 2.4
end
end
function I.linear_to_sRGB(val)
if val <= 0.0031308 then
return val * 12.92
else
return 1.055 * (val ^ (1/2.4)) - 0.055
end
end
function I.clamp(val, min, max)
return math.max(min, math.min(val, max))
end
function I.in_gamut(r, g, b)
return 0 <= r and r <= 1
and 0 <= g and g <= 1
and 0 <= b and b <= 1
end