internal/httpd/badgepng.go
161 lines · 4541 bytes
1package httpd
2
3import (
4 "bytes"
5 "image"
6 "image/color"
7 "image/draw"
8 "image/png"
9 "sync"
10
11 "golang.org/x/image/font"
12 "golang.org/x/image/font/gofont/goregular"
13 "golang.org/x/image/font/opentype"
14 "golang.org/x/image/math/fixed"
15)
16
17// The PNG badge, for readers that cannot decode SVG. iOS is the case that
18// forced it: UIImage decodes SVG only from an asset catalog, so a native
19// README view has no way to render the SVG badge. It is drawn at 2x because a
20// raster badge has no other way to stay sharp, and a badge carries no channel
21// to declare its own scale.
22
23const (
24 badgeScale = 2
25 badgeHeight = 20
26 badgeRadius = 3
27 badgeFontPt = 11
28)
29
30// badgeFace is parsed once. Go Regular, not the Verdana the SVG names: the SVG
31// asks a browser for a font it already has, while a raster badge has to carry
32// one, and the Go fonts are the only TTF in the module graph.
33var badgeFace = sync.OnceValues(func() (font.Face, error) {
34 parsed, err := opentype.Parse(goregular.TTF)
35 if err != nil {
36 return nil, err
37 }
38 return opentype.NewFace(parsed, &opentype.FaceOptions{
39 Size: badgeFontPt * badgeScale,
40 DPI: 72,
41 // Hinting rounds stems to the pixel grid, which is what keeps 11px text
42 // legible rather than smeared.
43 Hinting: font.HintingFull,
44 })
45})
46
47// badgePNG draws the same two-part pill badgeSVG describes.
48//
49// Widths are measured against the real face rather than reusing badgeWidth's
50// Verdana approximation, which exists only so the SVG need not ship metrics.
51// Here the metrics are already loaded, so the pill fits its text exactly.
52func badgePNG(label, state string) ([]byte, error) {
53 face, err := badgeFace()
54 if err != nil {
55 return nil, err
56 }
57 fill := badgeColors[state]
58 if fill == "" {
59 fill = badgeColors["unknown"]
60 }
61
62 pad := 10 * badgeScale
63 lw := font.MeasureString(face, label).Ceil() + pad
64 sw := font.MeasureString(face, state).Ceil() + pad
65 h := badgeHeight * badgeScale
66 r := badgeRadius * badgeScale
67
68 img := image.NewRGBA(image.Rect(0, 0, lw+sw, h))
69 // Three fills, as in the SVG: the grey pill, the coloured pill over its
70 // right half, then a square patch squaring off that pill's left edge.
71 fillRoundRect(img, image.Rect(0, 0, lw+sw, h), r, mustHex("#444d56"))
72 fillRoundRect(img, image.Rect(lw, 0, lw+sw, h), r, mustHex(fill))
73 draw.Draw(img, image.Rect(lw, 0, lw+2*r, h), image.NewUniform(mustHex(fill)), image.Point{}, draw.Src)
74
75 // y=14 of 20 in the SVG is the baseline; it is the same fraction here.
76 baseline := 14 * badgeScale
77 drawCentered(img, face, label, lw/2, baseline)
78 drawCentered(img, face, state, lw+sw/2, baseline)
79
80 var out bytes.Buffer
81 if err := png.Encode(&out, img); err != nil {
82 return nil, err
83 }
84 return out.Bytes(), nil
85}
86
87func drawCentered(dst *image.RGBA, face font.Face, s string, centerX, baseline int) {
88 width := font.MeasureString(face, s)
89 drawer := font.Drawer{
90 Dst: dst,
91 Src: image.NewUniform(color.White),
92 Face: face,
93 Dot: fixed.P(centerX, baseline).Sub(fixed.Point26_6{X: width / 2}),
94 }
95 drawer.DrawString(s)
96}
97
98// fillRoundRect fills r with c, rounding its four corners by radius.
99func fillRoundRect(dst *image.RGBA, r image.Rectangle, radius int, c color.Color) {
100 for y := r.Min.Y; y < r.Max.Y; y++ {
101 for x := r.Min.X; x < r.Max.X; x++ {
102 if insideRoundRect(x, y, r, radius) {
103 dst.Set(x, y, c)
104 }
105 }
106 }
107}
108
109func insideRoundRect(x, y int, r image.Rectangle, radius int) bool {
110 // Corner centres, inset by the radius. A pixel outside the rectangle the
111 // corners inscribe is in a corner, and belongs only if it is within radius
112 // of the nearest centre.
113 cx, cy := x, y
114 switch {
115 case x < r.Min.X+radius:
116 cx = r.Min.X + radius
117 case x >= r.Max.X-radius:
118 cx = r.Max.X - radius - 1
119 }
120 switch {
121 case y < r.Min.Y+radius:
122 cy = r.Min.Y + radius
123 case y >= r.Max.Y-radius:
124 cy = r.Max.Y - radius - 1
125 }
126 if cx == x && cy == y {
127 return true
128 }
129 dx, dy := x-cx, y-cy
130 return dx*dx+dy*dy <= radius*radius
131}
132
133// mustHex parses the "#rrggbb" literals badgeColors holds. They are constants
134// in this package, so a bad one is a programming error, not input.
135func mustHex(s string) color.RGBA {
136 var r, g, b uint8
137 for i, shift := range []uint{0, 1, 2} {
138 hi, lo := hexNibble(s[1+i*2]), hexNibble(s[2+i*2])
139 v := hi<<4 | lo
140 switch shift {
141 case 0:
142 r = v
143 case 1:
144 g = v
145 case 2:
146 b = v
147 }
148 }
149 return color.RGBA{R: r, G: g, B: b, A: 255}
150}
151
152func hexNibble(c byte) uint8 {
153 switch {
154 case c >= '0' && c <= '9':
155 return c - '0'
156 case c >= 'a' && c <= 'f':
157 return c - 'a' + 10
158 default:
159 return 0
160 }
161}