| 1 | package check |
| 2 | |
| 3 | // The WCAG and OKLCH arithmetic of rastrillo's ui/colour.go, from |
| 4 | // oklabToLinear through ContrastRatio, every name and signature as |
| 5 | // it was: the half the CSS gates and the derived-colour checks read. |
| 6 | // The colour engine above it (Pair, Wash, Allocate) stays in |
| 7 | // rastrillo; nothing here resolves a palette. |
| 8 | |
| 9 | import ( |
| 10 | "fmt" |
| 11 | "math" |
| 12 | ) |
| 13 | |
| 14 | // ─── OKLCH → sRGB ──────────────────────────────────────────────────── |
| 15 | // |
| 16 | // Björn Ottosson's oklab, the space CSS names in "in oklab" and the one |
| 17 | // the themes' own color-mix() calls interpolate in. Written out rather |
| 18 | // than pulled in: this is thirty lines of arithmetic and the framework |
| 19 | // takes no dependency for it. |
| 20 | |
| 21 | // oklabToLinear converts oklab to linear-light sRGB, which may fall |
| 22 | // outside 0..1 — that is what being out of gamut looks like, and the |
| 23 | // caller decides what to do about it. It takes the hue already resolved |
| 24 | // into its two rectangular components rather than OKLCh's degrees |
| 25 | // because the gamut bisection varies only the chroma: the hue's sine and |
| 26 | // cosine are computed once per colour instead of once per iteration. |
| 27 | func oklabToLinear(l, a, b float64) [3]float64 { |
| 28 | lc := l + 0.3963377774*a + 0.2158037573*b |
| 29 | mc := l - 0.1055613458*a - 0.0638541728*b |
| 30 | sc := l - 0.0894841775*a - 1.2914855480*b |
| 31 | lc, mc, sc = lc*lc*lc, mc*mc*mc, sc*sc*sc |
| 32 | |
| 33 | return [3]float64{ |
| 34 | +4.0767416621*lc - 3.3077115913*mc + 0.2309699292*sc, |
| 35 | -1.2684380046*lc + 2.6097574011*mc - 0.3413193965*sc, |
| 36 | -0.0041960863*lc - 0.7034186147*mc + 1.7076147010*sc, |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // linearToSRGB is the sRGB gamma transfer function. |
| 41 | func linearToSRGB(c float64) float64 { |
| 42 | if c <= 0.0031308 { |
| 43 | return c * 12.92 |
| 44 | } |
| 45 | return 1.055*math.Pow(c, 1/2.4) - 0.055 |
| 46 | } |
| 47 | |
| 48 | // inGamut reports whether a linear-light triple lands inside sRGB. The |
| 49 | // tolerance absorbs the last bit of floating-point error at the exact |
| 50 | // boundary; it is far below one eight-bit step. |
| 51 | func inGamut(lin [3]float64) bool { |
| 52 | for _, v := range lin { |
| 53 | if v < -1e-9 || v > 1+1e-9 { |
| 54 | return false |
| 55 | } |
| 56 | } |
| 57 | return true |
| 58 | } |
| 59 | |
| 60 | // oklchHex renders OKLCh as #rrggbb, reducing chroma until it fits sRGB, |
| 61 | // and returns the chroma it actually used. Lightness and hue are never |
| 62 | // touched: lightness is what the contrast floor is riding on, and hue is |
| 63 | // what the caller meant. |
| 64 | func oklchHex(l, c, hue float64) (string, float64) { |
| 65 | rgb, delivered := oklchRGB(l, c, hue) |
| 66 | return hexOf(rgb), delivered |
| 67 | } |
| 68 | |
| 69 | // oklchRGB is oklchHex without the string: the search runs it hundreds of |
| 70 | // times per call and throws most of the results away, so the eight-bit |
| 71 | // triple is the currency and a hex is minted only for the one that wins. |
| 72 | func oklchRGB(l, c, hue float64) ([3]uint8, float64) { |
| 73 | rad := hue * math.Pi / 180 |
| 74 | ca, sa := math.Cos(rad), math.Sin(rad) |
| 75 | if !inGamut(oklabToLinear(l, c*ca, c*sa)) { |
| 76 | lo, hi := 0.0, c |
| 77 | for i := 0; i < 40; i++ { |
| 78 | mid := (lo + hi) / 2 |
| 79 | if inGamut(oklabToLinear(l, mid*ca, mid*sa)) { |
| 80 | lo = mid |
| 81 | } else { |
| 82 | hi = mid |
| 83 | } |
| 84 | } |
| 85 | c = lo |
| 86 | } |
| 87 | lin := oklabToLinear(l, c*ca, c*sa) |
| 88 | var v [3]uint8 |
| 89 | for i, ch := range lin { |
| 90 | // Rounding to eight bits is the last step and the only clamp: |
| 91 | // by here the colour is in gamut, so this guards the rounding |
| 92 | // rather than mapping anything. |
| 93 | n := math.Round(linearToSRGB(ch) * 255) |
| 94 | v[i] = uint8(math.Min(255, math.Max(0, n))) |
| 95 | } |
| 96 | return v, c |
| 97 | } |
| 98 | |
| 99 | const hexDigits = "0123456789abcdef" |
| 100 | |
| 101 | // hexOf renders an eight-bit triple as lower-case #rrggbb. |
| 102 | func hexOf(v [3]uint8) string { |
| 103 | b := [7]byte{'#'} |
| 104 | for i, c := range v { |
| 105 | b[1+i*2] = hexDigits[c>>4] |
| 106 | b[2+i*2] = hexDigits[c&15] |
| 107 | } |
| 108 | return string(b[:]) |
| 109 | } |
| 110 | |
| 111 | // oklabOf converts eight-bit sRGB to OKLab — the way in, where |
| 112 | // oklchToLinear is the way out. Two uses: asking which candidate fill is |
| 113 | // nearest the background (which wants a perceptual scale rather than a |
| 114 | // luminance one), and measuring how far apart two resolved fills are. |
| 115 | func oklabOf(r, g, b uint8) [3]float64 { |
| 116 | rl := linearFromSRGB(float64(r) / 255) |
| 117 | gl := linearFromSRGB(float64(g) / 255) |
| 118 | bl := linearFromSRGB(float64(b) / 255) |
| 119 | lc := math.Cbrt(0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl) |
| 120 | mc := math.Cbrt(0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl) |
| 121 | sc := math.Cbrt(0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl) |
| 122 | return [3]float64{ |
| 123 | 0.2104542553*lc + 0.7936177850*mc - 0.0040720468*sc, |
| 124 | 1.9779984951*lc - 2.4285922050*mc + 0.4505937099*sc, |
| 125 | 0.0259040371*lc + 0.7827717662*mc - 0.8086757660*sc, |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // MinSeparation is the least perceptual distance two fills may sit at and |
| 130 | // still be told apart: 0.03 in OKLab, which is a little over one |
| 131 | // just-noticeable difference (about 0.02 for a large flat patch). |
| 132 | // |
| 133 | // It exists because "twelve hues thirty degrees apart" is a statement |
| 134 | // about intents and not about colours. Two intents that are far apart as |
| 135 | // angles can resolve within a hair of each other once a background has |
| 136 | // forced them both to a low lightness, where there is no room left to be |
| 137 | // saturated in — and at that point Allocate's separation guarantee is |
| 138 | // true about slots and false about the screen. The shipped set's worst |
| 139 | // pair is 0.045, on day's dark page, so the floor is not fitted to pass. |
| 140 | // |
| 141 | // Exported for the same reason the contrast floors are: an app checking |
| 142 | // its own intents against its own canvas should hold the same bar. |
| 143 | const MinSeparation = 0.03 |
| 144 | |
| 145 | // DeltaEOK is the perceptual distance between two colours: plain |
| 146 | // euclidean distance in OKLab, which is what OKLab is for. Roughly, 0.02 |
| 147 | // is the point two large flat patches stop being reliably tellable |
| 148 | // apart, and 0.1 is comfortably different. |
| 149 | // |
| 150 | // Exported because a caller mapping an arbitrary colour onto the offered |
| 151 | // set — an imported XLSX fill, a pasted highlight — needs a perceptual |
| 152 | // nearest-match, and re-deriving one from a hue angle gets it wrong at |
| 153 | // exactly the low lightnesses where it matters. |
| 154 | func DeltaEOK(a, b string) (float64, error) { |
| 155 | ar, ag, ab, err := parseHex(a) |
| 156 | if err != nil { |
| 157 | return 0, fmt.Errorf("first colour: %w", err) |
| 158 | } |
| 159 | br, bg, bb, err := parseHex(b) |
| 160 | if err != nil { |
| 161 | return 0, fmt.Errorf("second colour: %w", err) |
| 162 | } |
| 163 | return labDistance(oklabOf(ar, ag, ab), oklabOf(br, bg, bb)), nil |
| 164 | } |
| 165 | |
| 166 | // labDistance is DeltaEOK's kernel, for callers inside this package that |
| 167 | // already hold the OKLab triples. One notion of perceptual distance, one |
| 168 | // place it is written down: the separation check over an offered set, a |
| 169 | // swatch's Separation, and the wash floor are all this function. |
| 170 | func labDistance(a, b [3]float64) float64 { |
| 171 | dl, da, db := a[0]-b[0], a[1]-b[1], a[2]-b[2] |
| 172 | return math.Sqrt(dl*dl + da*da + db*db) |
| 173 | } |
| 174 | |
| 175 | // linearFromSRGB is the inverse gamma transfer, at the sRGB spec's own |
| 176 | // 0.04045 breakpoint. It is not srgbToLinear below, which uses WCAG 2.x's |
| 177 | // 0.03928 — the two differ in the fourth decimal place of one threshold |
| 178 | // and are kept apart deliberately, so the WCAG numbers this file publishes |
| 179 | // are the numbers WCAG's formula gives. |
| 180 | func linearFromSRGB(c float64) float64 { |
| 181 | if c <= 0.04045 { |
| 182 | return c / 12.92 |
| 183 | } |
| 184 | return math.Pow((c+0.055)/1.055, 2.4) |
| 185 | } |
| 186 | |
| 187 | // ─── WCAG arithmetic ───────────────────────────────────────────────── |
| 188 | // |
| 189 | // This used to live in contrast_test.go and be reachable only from a |
| 190 | // test. It is shipped code now because the colour engine is built on it |
| 191 | // and because a caller writing its own gate should hold its own colours |
| 192 | // to this arithmetic rather than to a second copy of it. The theme |
| 193 | // contrast gate calls these, so there is exactly one implementation and |
| 194 | // TestContrastMathMatchesDocumentedDangerFillRatios calibrates the |
| 195 | // shipped one. |
| 196 | |
| 197 | // parseHex reads a #rgb or #rrggbb literal into eight-bit channels. |
| 198 | // Anything else — var(), color-mix(), rgba(), a bare colour name, a |
| 199 | // value with whitespace around it — is an error rather than a guess, |
| 200 | // because a colour this package cannot read is a colour it must not |
| 201 | // silently substitute something for. |
| 202 | func parseHex(s string) (r, g, b uint8, err error) { |
| 203 | // Hand-decoded rather than matched against a regexp: ContrastRatio |
| 204 | // is exported API that two apps will call in loops, and a regexp |
| 205 | // plus three Sscanf calls cost thirty times the arithmetic they |
| 206 | // feed. The accepted grammar is unchanged — #rgb or #rrggbb, no |
| 207 | // whitespace, nothing else. |
| 208 | var v [3]uint8 |
| 209 | switch len(s) { |
| 210 | case 4: |
| 211 | if s[0] != '#' { |
| 212 | return 0, 0, 0, notHex(s) |
| 213 | } |
| 214 | for i := 0; i < 3; i++ { |
| 215 | n, ok := nibble(s[1+i]) |
| 216 | if !ok { |
| 217 | return 0, 0, 0, notHex(s) |
| 218 | } |
| 219 | v[i] = n<<4 | n |
| 220 | } |
| 221 | case 7: |
| 222 | if s[0] != '#' { |
| 223 | return 0, 0, 0, notHex(s) |
| 224 | } |
| 225 | for i := 0; i < 3; i++ { |
| 226 | hi, okHi := nibble(s[1+i*2]) |
| 227 | lo, okLo := nibble(s[2+i*2]) |
| 228 | if !okHi || !okLo { |
| 229 | return 0, 0, 0, notHex(s) |
| 230 | } |
| 231 | v[i] = hi<<4 | lo |
| 232 | } |
| 233 | default: |
| 234 | return 0, 0, 0, notHex(s) |
| 235 | } |
| 236 | return v[0], v[1], v[2], nil |
| 237 | } |
| 238 | |
| 239 | func notHex(s string) error { |
| 240 | return fmt.Errorf("not a #rgb/#rrggbb literal: %q", s) |
| 241 | } |
| 242 | |
| 243 | func nibble(c byte) (uint8, bool) { |
| 244 | switch { |
| 245 | case c >= '0' && c <= '9': |
| 246 | return c - '0', true |
| 247 | case c >= 'a' && c <= 'f': |
| 248 | return c - 'a' + 10, true |
| 249 | case c >= 'A' && c <= 'F': |
| 250 | return c - 'A' + 10, true |
| 251 | } |
| 252 | return 0, false |
| 253 | } |
| 254 | |
| 255 | // srgbToLinear converts one sRGB channel (0..1) to its linearized form — |
| 256 | // the standard WCAG 2.x formula (also used by the WCAG 2.2 spec this |
| 257 | // project targets). |
| 258 | func srgbToLinear(c float64) float64 { |
| 259 | if c <= 0.03928 { |
| 260 | return c / 12.92 |
| 261 | } |
| 262 | return math.Pow((c+0.055)/1.055, 2.4) |
| 263 | } |
| 264 | |
| 265 | // srgbLinearTable is srgbToLinear for all 256 eight-bit values. Every |
| 266 | // colour this package measures has already been rounded to eight bits, so |
| 267 | // there are only 256 answers and a table is the whole domain rather than |
| 268 | // a cache of part of it. Built from srgbToLinear itself, so the two |
| 269 | // cannot say different things. |
| 270 | var srgbLinearTable = func() (t [256]float64) { |
| 271 | for i := range t { |
| 272 | t[i] = srgbToLinear(float64(i) / 255) |
| 273 | } |
| 274 | return t |
| 275 | }() |
| 276 | |
| 277 | // relLuminance is the WCAG relative luminance of an sRGB colour. |
| 278 | func relLuminance(r, g, b uint8) float64 { |
| 279 | return 0.2126*srgbLinearTable[r] + 0.7152*srgbLinearTable[g] + 0.0722*srgbLinearTable[b] |
| 280 | } |
| 281 | |
| 282 | // ratioOf is the WCAG ratio between two relative luminances. Split out so |
| 283 | // the search can compare luminances it already has instead of re-parsing |
| 284 | // two hex strings, and so there is still exactly one place the formula is |
| 285 | // written down. |
| 286 | func ratioOf(a, b float64) float64 { |
| 287 | if b > a { |
| 288 | a, b = b, a |
| 289 | } |
| 290 | return (a + 0.05) / (b + 0.05) |
| 291 | } |
| 292 | |
| 293 | // ContrastRatio is the WCAG contrast ratio between two hex colours: |
| 294 | // (lighter + 0.05) / (darker + 0.05) over relative luminance. It is |
| 295 | // symmetric — there is no foreground and background here, only two |
| 296 | // colours — and a colour against itself is exactly 1. |
| 297 | // |
| 298 | // Exported for the same reason the floors are: an app gating its own |
| 299 | // colours should measure them with the framework's arithmetic, not a |
| 300 | // re-typed one that rounds differently at the boundary. |
| 301 | func ContrastRatio(a, b string) (float64, error) { |
| 302 | ar, ag, ab, err := parseHex(a) |
| 303 | if err != nil { |
| 304 | return 0, fmt.Errorf("first colour: %w", err) |
| 305 | } |
| 306 | br, bg, bb, err := parseHex(b) |
| 307 | if err != nil { |
| 308 | return 0, fmt.Errorf("second colour: %w", err) |
| 309 | } |
| 310 | return ratioOf(relLuminance(ar, ag, ab), relLuminance(br, bg, bb)), nil |
| 311 | } |
| 312 | |