oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1// colors.go converts between sRGB hex (what XLSX fills use) and OKLCH
2// (what Format.FillIntentH/FillIntentC store — models.go). The app's own
3// palette allocation — which offered swatch a hue/chroma pair snaps to —
4// lives in the design-system "ui" package (spec §10.3), which this repo
5// does not have: it is plan 2. What lives here is the underlying, palette-
6// independent color math: a hex an XLSX file wasn't necessarily authored
7// with one of our swatches gets its own true OKLCH hue and chroma computed
8// directly, rather than snapped to a fixed list this package cannot see.
9// FillHex (models.go) is what actually drives export when present, so
10// this conversion is never the lossy half of a round trip — see
11// TestImportedFillHexIsRetained.
12package xlsx
13
14import (
15 "fmt"
16 "math"
17 "strconv"
18 "strings"
19)
20
21// defaultFillLightness is the OKLCH lightness used to turn an intent
22// (hue/chroma only — Format has no lightness field, because in the app's
23// own color system lightness is chosen per-theme, not stored) back into a
24// concrete hex for export, for the one case that has no retained FillHex
25// to fall back on: a fill picked purely by intent, with no source hex,
26// which nothing in this backend-only repo can yet produce (there is no
27// palette-picker UI here — plan 2), but that Export must still handle
28// rather than assume can't happen. 0.7 sits in the middle of OKLab's
29// perceptual lightness range: neither so light a saturated hue clips at
30// white nor so dark it clips at black for the chromas cell fills use.
31const defaultFillLightness = 0.7
32
33// hexToOKLCH parses a fill hex (either 6-char RRGGBB or 8-char ARGB —
34// this package always retains and emits 8-char ARGB internally, see
35// argbHex, but import accepts either) and returns its OKLCH hue (degrees,
36// 0-360) and chroma. err is non-nil (and coordinate-free — callers attach
37// position) for a string that is not valid hex of one of those two
38// lengths.
39func hexToOKLCH(hex string) (hue, chroma float64, err error) {
40 r, g, b, err := parseHexRGB(hex)
41 if err != nil {
42 return 0, 0, err
43 }
44 l, c, h := srgbToOKLCH(r, g, b)
45 _ = l
46 return h, c, nil
47}
48
49// oklchToHex is hexToOKLCH's inverse for export, at a fixed lightness
50// (see defaultFillLightness) since Format carries no lightness of its
51// own. It always returns 8-char ARGB (alpha FF) — argbHex's shape.
52func oklchToHex(hue, chroma float64) string {
53 r, g, b := oklchToSRGB(defaultFillLightness, chroma, hue)
54 return argbHex(r, g, b)
55}
56
57// parseHexRGB accepts "RRGGBB" or "AARRGGBB" (case-insensitive, with or
58// without a leading '#') and returns 0-1 linear-light-free (still gamma
59// encoded) sRGB channel values in 0-255 form as float64, ignoring alpha.
60func parseHexRGB(hex string) (r, g, b float64, err error) {
61 s := strings.TrimPrefix(hex, "#")
62 switch len(s) {
63 case 8:
64 s = s[2:] // drop alpha
65 case 6:
66 // already RRGGBB
67 default:
68 return 0, 0, 0, fmt.Errorf("xlsx: fill hex has invalid length %d", len(s))
69 }
70 v, err := strconv.ParseUint(s, 16, 32)
71 if err != nil {
72 return 0, 0, 0, fmt.Errorf("xlsx: fill hex is not valid hexadecimal")
73 }
74 r = float64((v >> 16) & 0xFF)
75 g = float64((v >> 8) & 0xFF)
76 b = float64(v & 0xFF)
77 return r, g, b, nil
78}
79
80// argbHex renders 0-255 sRGB channels as this package's canonical 8-char
81// ARGB fill hex, alpha forced to FF: XLSX cell fills are opaque in every
82// case this app produces, and excelize's own Style.Fill.Color round trip
83// (verified against the library directly) always implies FF alpha too —
84// see import.go's readFillHex.
85func argbHex(r, g, b float64) string {
86 clamp := func(x float64) int {
87 if x < 0 {
88 return 0
89 }
90 if x > 255 {
91 return 255
92 }
93 return int(math.Round(x))
94 }
95 return fmt.Sprintf("FF%02X%02X%02X", clamp(r), clamp(g), clamp(b))
96}
97
98// --- OKLab/OKLCH <-> sRGB, Björn Ottosson's published transform ---
99// (https://bottosson.github.io/posts/oklab/). Standard, palette-
100// independent color science — not app-specific, which is exactly why it
101// belongs in this backend package rather than waiting on the "ui" one.
102
103func srgbChannelToLinear(c float64) float64 {
104 c /= 255
105 if c <= 0.04045 {
106 return c / 12.92
107 }
108 return math.Pow((c+0.055)/1.055, 2.4)
109}
110
111func linearToSRGBChannel(c float64) float64 {
112 if c <= 0.0031308 {
113 return c * 12.92 * 255
114 }
115 return (1.055*math.Pow(c, 1/2.4) - 0.055) * 255
116}
117
118// srgbToOKLCH converts 0-255 sRGB channels to OKLCH (L in 0-1, C >= 0,
119// H in degrees 0-360).
120func srgbToOKLCH(r255, g255, b255 float64) (l, c, h float64) {
121 r := srgbChannelToLinear(r255)
122 g := srgbChannelToLinear(g255)
123 b := srgbChannelToLinear(b255)
124
125 lc := 0.4122214708*r + 0.5363325363*g + 0.0514459929*b
126 m := 0.2119034982*r + 0.6806995451*g + 0.1073969566*b
127 s := 0.0883024619*r + 0.2817188376*g + 0.6299787005*b
128
129 lc, m, s = cbrt(lc), cbrt(m), cbrt(s)
130
131 L := 0.2104542553*lc + 0.7936177850*m - 0.0040720468*s
132 a := 1.9779984951*lc - 2.4285922050*m + 0.4505937099*s
133 bb := 0.0259040371*lc + 0.7827717662*m - 0.8086757660*s
134
135 C := math.Hypot(a, bb)
136 H := math.Atan2(bb, a) * 180 / math.Pi
137 if H < 0 {
138 H += 360
139 }
140 return L, C, H
141}
142
143// oklchToSRGB is srgbToOKLCH's inverse, returning 0-255 sRGB channels
144// (unclamped — argbHex clamps at render time).
145func oklchToSRGB(l, c, h float64) (r255, g255, b255 float64) {
146 hr := h * math.Pi / 180
147 a := c * math.Cos(hr)
148 b := c * math.Sin(hr)
149
150 lc := l + 0.3963377774*a + 0.2158037573*b
151 m := l - 0.1055613458*a - 0.0638541728*b
152 s := l - 0.0894841775*a - 1.2914855480*b
153
154 lc, m, s = lc*lc*lc, m*m*m, s*s*s
155
156 r := +4.0767416621*lc - 3.3077115913*m + 0.2309699292*s
157 g := -1.2684380046*lc + 2.6097574011*m - 0.3413193965*s
158 bl := -0.0041960863*lc - 0.7034186147*m + 1.7076147010*s
159
160 return linearToSRGBChannel(r), linearToSRGBChannel(g), linearToSRGBChannel(bl)
161}
162
163func cbrt(x float64) float64 {
164 if x < 0 {
165 return -math.Cbrt(-x)
166 }
167 return math.Cbrt(x)
168}
169
170// IntentHex renders a colour intent at a given lightness.
171//
172// Exported for the app's contrast gate, which has to answer "does every
173// swatch we offer clear WCAG AA at both themes' lightnesses" and cannot
174// do that without the same OKLCH->sRGB transform the exporter uses. Two
175// implementations of Björn Ottosson's transform, one in Go and one
176// implied by the browser's oklch(), is already one more than ideal;
177// three — with a test carrying its own copy — would make the gate
178// measure something other than what ships.
179//
180// l is 0-1, as everywhere else in this file. The result is 8-char ARGB.
181func IntentHex(l, chroma, hue float64) string {
182 r, g, b := oklchToSRGB(l, chroma, hue)
183 return argbHex(r, g, b)
184}
185