Serve build badges as PNG as well as SVG !143

merged merged by cmc on 2026-08-31 23:17 UTC · krz/gitbay:badge-png into main

6 files changed, +268 −7

Layout: unified · split

go.mod +1
@@ -32,6 +32,7 @@ require (
32 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 32 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
33 github.com/russross/blackfriday/v2 v2.1.0 // indirect 33 github.com/russross/blackfriday/v2 v2.1.0 // indirect
34 github.com/spf13/pflag v1.0.9 // indirect 34 github.com/spf13/pflag v1.0.9 // indirect
35 golang.org/x/image v0.45.0 // indirect
35 golang.org/x/sys v0.47.0 // indirect 36 golang.org/x/sys v0.47.0 // indirect
36 golang.org/x/text v0.41.0 // indirect 37 golang.org/x/text v0.41.0 // indirect
37 modernc.org/libc v1.74.4 // indirect 38 modernc.org/libc v1.74.4 // indirect
go.sum +2
@@ -65,6 +65,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
65go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 65go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
66golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= 66golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
67golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= 67golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
68golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
69golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
68golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= 70golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
69golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 71golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
70golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= 72golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
internal/httpd/badge.go +34 −7
@@ -62,28 +62,55 @@ func badgeSVG(label, state string) string {
62 62
63// buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name]. 63// buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name].
64func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) { 64func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) {
65 repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git")) 65 label, state, ok := s.badgeState(r)
66 if !ok {
67 http.NotFound(w, r)
68 return
69 }
70 badgeHeaders(w, "image/svg+xml; charset=utf-8")
71 fmt.Fprint(w, badgeSVG(label, state))
72}
73
74// buildBadgePNG answers GET /{owner}/{repo}/badge/build.png[?job=name], for
75// readers that cannot decode SVG.
76func (s *Server) buildBadgePNG(w http.ResponseWriter, r *http.Request) {
77 label, state, ok := s.badgeState(r)
66 if !ok { 78 if !ok {
67 http.NotFound(w, r) 79 http.NotFound(w, r)
68 return 80 return
69 } 81 }
82 out, err := badgePNG(label, state)
83 if err != nil {
84 http.Error(w, "badge unavailable", http.StatusInternalServerError)
85 return
86 }
87 badgeHeaders(w, "image/png")
88 w.Write(out)
89}
90
91// badgeState resolves the repo and its latest build. Private repositories
92// report not ok — a badge must not leak that a repo exists.
93func (s *Server) badgeState(r *http.Request) (label, state string, ok bool) {
94 repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git"))
95 if !ok {
96 return "", "", false
97 }
70 job := r.URL.Query().Get("job") 98 job := r.URL.Query().Get("job")
71 label := "build" 99 label = "build"
72 if job != "" { 100 if job != "" {
73 label = job 101 label = job
74 } 102 }
75 state := "unknown" 103 state = "unknown"
76 if b, err := s.st.LatestBuild(repo.ID, job); err == nil { 104 if b, err := s.st.LatestBuild(repo.ID, job); err == nil {
77 state = b.Status 105 state = b.Status
78 } 106 }
79 writeBadge(w, label, state) 107 return label, state, true
80} 108}
81 109
82func writeBadge(w http.ResponseWriter, label, state string) { 110func badgeHeaders(w http.ResponseWriter, contentType string) {
83 w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") 111 w.Header().Set("Content-Type", contentType)
84 // Badges are read by other people's caches; keep them briefly fresh 112 // Badges are read by other people's caches; keep them briefly fresh
85 // rather than pinned to a stale result. 113 // rather than pinned to a stale result.
86 w.Header().Set("Cache-Control", "max-age=60, must-revalidate") 114 w.Header().Set("Cache-Control", "max-age=60, must-revalidate")
87 w.Header().Set("X-Content-Type-Options", "nosniff") 115 w.Header().Set("X-Content-Type-Options", "nosniff")
88 fmt.Fprint(w, badgeSVG(label, state))
89} 116}
internal/httpd/badgepng.go added +161
@@ -0,0 +1,161 @@
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}
internal/httpd/badgepng_test.go added +69
@@ -0,0 +1,69 @@
1package httpd
2
3import (
4 "bytes"
5 "image"
6 "image/color"
7 "image/png"
8 "testing"
9)
10
11func decodeBadge(t *testing.T, label, state string) image.Image {
12 t.Helper()
13 out, err := badgePNG(label, state)
14 if err != nil {
15 t.Fatalf("badgePNG(%q, %q): %v", label, state, err)
16 }
17 img, err := png.Decode(bytes.NewReader(out))
18 if err != nil {
19 t.Fatalf("decode %q: %v", state, err)
20 }
21 return img
22}
23
24func TestBadgePNGGeometry(t *testing.T) {
25 img := decodeBadge(t, "build", "success")
26 if got, want := img.Bounds().Dy(), badgeHeight*badgeScale; got != want {
27 t.Errorf("height = %d, want %d", got, want)
28 }
29 // A longer label makes a wider badge; the pill is sized to its text.
30 if decodeBadge(t, "integration", "success").Bounds().Dx() <= img.Bounds().Dx() {
31 t.Error("a longer label did not widen the badge")
32 }
33}
34
35func TestBadgePNGUsesTheStateColour(t *testing.T) {
36 // A point inside the right-hand pill, clear of the rounded corners.
37 for state, hex := range badgeColors {
38 img := decodeBadge(t, "build", state)
39 b := img.Bounds()
40 got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA)
41 if want := mustHex(hex); got != want {
42 t.Errorf("%s: pill colour = %v, want %v", state, got, want)
43 }
44 }
45}
46
47// An unknown state takes the grey the SVG gives it rather than a transparent pill.
48func TestBadgePNGUnknownStateFallsBack(t *testing.T) {
49 img := decodeBadge(t, "build", "no-such-state")
50 b := img.Bounds()
51 got := color.RGBAModel.Convert(img.At(b.Max.X-4, b.Dy()/2)).(color.RGBA)
52 if want := mustHex(badgeColors["unknown"]); got != want {
53 t.Errorf("pill colour = %v, want %v", got, want)
54 }
55}
56
57// The corners are rounded, so the very corner pixel is left transparent.
58func TestBadgePNGCornersAreRounded(t *testing.T) {
59 img := decodeBadge(t, "build", "success")
60 if _, _, _, a := img.At(img.Bounds().Min.X, img.Bounds().Min.Y).RGBA(); a != 0 {
61 t.Errorf("top-left alpha = %d, want 0", a)
62 }
63}
64
65func TestMustHex(t *testing.T) {
66 if got, want := mustHex("#2da44e"), (color.RGBA{R: 0x2d, G: 0xa4, B: 0x4e, A: 255}); got != want {
67 t.Errorf("mustHex = %v, want %v", got, want)
68 }
69}
internal/httpd/routes.go +1
@@ -59,6 +59,7 @@ func (s *Server) Routes() []Route {
59 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, 59 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
60 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 60 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
61 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, 61 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build}, 63 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build},
63 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, 64 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
64 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, 65 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},