# select_rgb.lua -rw-r--r-- 5.8 KiB View raw
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
-- 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