-- 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