oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "math"
5 "strconv"
6
7 "amadan.net/oficina/sheets/internal/sheets/store"
8)
9
10// aaFloor is WCAG 2.2's contrast minimum for normal-size text: 4.5:1.
11const aaFloor = 4.5
12
13// maxWarnedCells bounds the list a page names. Somebody importing a
14// heavily formatted file does not need four hundred cell references;
15// they need to know it happened and where to start.
16const maxWarnedCells = 12
17
18// contrastRatio is WCAG 2.x relative luminance, (L1+0.05)/(L2+0.05).
19//
20// Written here rather than borrowed: rastrillo has this computation,
21// but only inside ui/contrast_test.go, and a _test.go file exports
22// nothing to another package. Copying twenty lines of a published
23// standard beats vendoring a test file or waiting for an upstream
24// export — and the constants below are the standard's, checkable
25// against it rather than against us.
26func contrastRatio(a, b string) (float64, bool) {
27 la, ok := relativeLuminance(a)
28 if !ok {
29 return 0, false
30 }
31 lb, ok := relativeLuminance(b)
32 if !ok {
33 return 0, false
34 }
35 if la < lb {
36 la, lb = lb, la
37 }
38 return (la + 0.05) / (lb + 0.05), true
39}
40
41// relativeLuminance parses an 8-char ARGB or 6-char RGB hex and returns
42// WCAG relative luminance. The alpha channel is ignored: a fill in a
43// spreadsheet is composited over the sheet, and treating a translucent
44// fill as opaque overstates rather than understates the risk, which is
45// the safe direction for a warning.
46func relativeLuminance(hex string) (float64, bool) {
47 switch len(hex) {
48 case 8:
49 hex = hex[2:]
50 case 6:
51 default:
52 return 0, false
53 }
54 channel := func(s string) (float64, bool) {
55 v, err := strconv.ParseUint(s, 16, 8)
56 if err != nil {
57 return 0, false
58 }
59 c := float64(v) / 255
60 if c <= 0.04045 {
61 return c / 12.92, true
62 }
63 return math.Pow((c+0.055)/1.055, 2.4), true
64 }
65 r, okR := channel(hex[0:2])
66 g, okG := channel(hex[2:4])
67 b, okB := channel(hex[4:6])
68 if !okR || !okG || !okB {
69 return 0, false
70 }
71 return 0.2126*r + 0.7152*g + 0.0722*b, true
72}
73
74// lowContrast reports whether a format's AUTHORED pair fails AA.
75//
76// Both halves must be present. A fill on its own cannot fail: our own
77// ink comes from the theme, and that pairing is one this app controls
78// and gates elsewhere. What can fail is a fill and an ink that both
79// arrived in somebody's file — which is not hypothetical, since Excel
80// ships #9C6500 on #FFEB9C at 4.12:1 as a conditional-formatting
81// preset (spec §11.1).
82//
83// It reports, and nothing acts on it beyond telling a person. Spec
84// §11.1 ruled that we render an imported file as authored: silently
85// correcting it would lie about the document. The obligation is
86// surfacing, not overriding.
87func lowContrast(f *store.Format) bool {
88 if f == nil || f.FillHex == "" || f.InkHex == "" {
89 return false
90 }
91 ratio, ok := contrastRatio(f.FillHex, f.InkHex)
92 if !ok {
93 return false
94 }
95 return ratio < aaFloor
96}
97
98// warnedCells lists the A1 references in a window whose imported
99// colours fail AA, newest window first and capped.
100func warnedCells(h *store.Handle, idx posIndex, rows, cols int) []string {
101 coords, err := h.Cells()
102 if err != nil {
103 return nil
104 }
105 var refs []string
106 for _, c := range coords {
107 r, okR := idx.rowPos[c.RowID]
108 col, okC := idx.colPos[c.ColID]
109 if !okR || !okC || r >= rows || col >= cols {
110 continue
111 }
112 if !lowContrast(h.Format(c.RowID, c.ColID)) {
113 continue
114 }
115 if len(refs) == maxWarnedCells {
116 return append(refs, "…")
117 }
118 refs = append(refs, colLabel(col)+strconv.Itoa(r+1))
119 }
120 return refs
121}
122