rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package 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
9import (
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.
27func 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.
41func 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.
51func 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.
64func 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.
72func 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
99const hexDigits = "0123456789abcdef"
100
101// hexOf renders an eight-bit triple as lower-case #rrggbb.
102func 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.
115func 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.
143const 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.
154func 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.
170func 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.
180func 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.
202func 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
239func notHex(s string) error {
240 return fmt.Errorf("not a #rgb/#rrggbb literal: %q", s)
241}
242
243func 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).
258func 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.
270var 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.
278func 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.
286func 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.
301func 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