internal/httpd/badgepng.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/httpd/badgepng.go history · blame · raw

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}