package badges
import (
"image/color"
)
// Badge contains all of the characteristics of a badge.
type Badge struct {
Caption string
Text string
Color color.Color
Height int
MinWidth int
}
// ApplyDefaults ensures that basic properties of the badge, such as height and
// color, are set.
func (b *Badge) ApplyDefaults() {
if b.Height <= 0 {
b.Height = 20
}
if b.Color == nil {
b.Color = InfoColor
}
}
package badges
import (
"image/color"
"strconv"
"strings"
)
// Standard colors for badges.
var (
PassColor color.Color = color.RGBA{R: 0x44, G: 0xCC, B: 0x11, A: 0xFF}
WarnColor color.Color = color.RGBA{R: 0xCC, G: 0xCC, B: 0x11, A: 0xFF}
FailColor color.Color = color.RGBA{R: 0xCC, G: 0x44, B: 0x11, A: 0xFF}
InfoColor color.Color = color.RGBA{R: 0, G: 0x88, B: 0xCC, A: 0xFF}
)
// ParseColor parses a string to determine a badge color. It accepts names for
// the standard colors ("pass", "warn", "fail", and "info"), but also hex colors
// when prefixed by a hash.
func ParseColor(clr string) (color.Color, error) {
clr = strings.ToLower(clr)
if clr == "pass" {
return PassColor, nil
}
if clr == "warn" {
return WarnColor, nil
}
if clr == "fail" {
return FailColor, nil
}
if clr == "info" {
return InfoColor, nil
}
if strings.HasPrefix(clr, "#") {
if len(clr) == 4 {
num, err := strconv.ParseUint(clr[1:], 16, 12)
if err != nil {
return nil, &strconv.NumError{
Func: "ParseColor",
Num: clr,
Err: strconv.ErrSyntax,
}
}
r := uint8((num >> 8) & 0xf)
g := uint8((num >> 4) & 0xf)
b := uint8(num & 0xf)
return color.RGBA{R: r<<4 | r, G: g<<4 | g, B: b<<4 | b, A: 0xff}, nil
}
if len(clr) == 7 {
num, err := strconv.ParseUint(clr[1:], 16, 24)
if err != nil {
return nil, &strconv.NumError{
Func: "ParseColor",
Num: clr,
Err: strconv.ErrSyntax,
}
}
r := uint8((num >> 16) & 0xff)
g := uint8((num >> 8) & 0xff)
b := uint8(num & 0xff)
return color.RGBA{R: r, G: g, B: b, A: 0xff}, nil
}
}
return nil, &strconv.NumError{
Func: "ParseColor",
Num: clr,
Err: strconv.ErrSyntax,
}
}
package badges
import (
"image"
"image/color"
"image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
var (
gray = image.NewUniform(color.Gray16{0x555f})
shadow = image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 0x44})
)
// DrawImage draws the badge into an image.RGBA.
func (b *Badge) DrawImage() *image.RGBA {
width1, width2, baselineOffset := b.CalculateWidths()
dst := image.NewRGBA(image.Rect(0, 0, width1+width2, b.Height))
draw.Draw(dst, dst.Bounds(), gray, image.Point{}, draw.Over)
draw.Draw(dst, dst.Bounds().Add(image.Point{width1, 0}), image.NewUniform(b.Color), image.Point{}, draw.Over)
drawer := font.Drawer{
Dst: dst,
Src: image.White,
Face: basicfont.Face7x13,
}
baseline := b.Height/2 + baselineOffset
drawer.Dst = dst
drawer.Dot = fixed.P(6, baseline)
drawer.Src = shadow
drawer.DrawString(b.Caption)
drawer.Dot = fixed.P(6, baseline-1)
drawer.Src = image.White
drawer.DrawString(b.Caption)
drawer.Dot = fixed.P(width1+6, baseline)
drawer.Src = shadow
drawer.DrawString(b.Text)
drawer.Dot = fixed.P(width1+6, baseline-1)
drawer.Src = image.White
drawer.DrawString(b.Text)
// Round the corners
dst.Set(0, 0, color.Transparent)
dst.Set(0, b.Height-1, color.Transparent)
dst.Set(width1+width2-1, 0, color.Transparent)
dst.Set(width1+width2-1, b.Height-1, color.Transparent)
return dst
}
// CalculateWidths determine the width for both halves of the badge.
func (b *Badge) CalculateWidths() (int, int, int) {
// Measure size needed for the badge based on the caption and status text.
dst := image.NewRGBA(image.Rect(0, 0, 128, b.Height))
drawer := font.Drawer{
Dst: dst,
Src: image.White,
Face: basicfont.Face7x13,
}
bounds1, _ := drawer.BoundString(b.Caption)
bounds2, _ := drawer.BoundString(b.Text)
width1 := 6 + (bounds1.Max.X - bounds1.Min.X).Ceil() + 6
width2 := 6 + (bounds2.Max.X - bounds2.Min.X).Ceil() + 6
baselineOffset := ((bounds1.Max.Y - bounds1.Min.Y) / 2).Ceil() - bounds1.Max.Y.Ceil()
// Apply the minimum width by distributing extra space to width1 and width2
if width1+width2 < b.MinWidth {
delta := b.MinWidth - width1 - width2
width1 += delta / 2
width2 += delta - delta/2
}
return width1, width2, baselineOffset
}
package badges
import (
"fmt"
"image/color"
"git.sr.ht/~rj/sgr"
)
// DrawSGR draws the badge with text using escape ANSI escape codes.
func (b *Badge) DrawSGR() string {
f := sgr.NewFormatterWithANSI()
s1 := f.NewStyle(sgr.FG(sgr.White), sgr.BG24(0x50, 0x50, 0x50))
s2 := f.NewStyle(sgr.FG(sgr.White), makeBG24(b.Color))
return fmt.Sprintf("%s%s",
f.Style(s1, " %-8s ", b.Caption),
f.Style(s2, " %-5s ", b.Text),
)
}
func makeBG24(c color.Color) sgr.StyleOption {
r, g, b, _ := c.RGBA()
return sgr.BG24(uint8(r>>8), uint8(g>>8), uint8(b>>8))
}
package badges
import (
"fmt"
)
// DrawSVG draws the badge into an SVG image.
func (b *Badge) DrawSVG() string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="%[6]d" height="%[7]d">
<clipPath id="a"><rect width="%[6]d" height="%[7]d" rx="2" fill="#fff"/></clipPath>
<g clip-path="url(#a)">
<path fill="#555" d="M0 0h%[6]dv%[7]dH0z"/>
<path fill="#%02[3]x%02[4]x%02[5]x" d="M%[8]d 0h%[9]dv%[7]dH%[8]dz"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%[10]d" y="%[14]d" textLength="%[11]d" fill="#000" fill-opacity=".3">%[1]s</text>
<text x="%[10]d" y="%[15]d" textLength="%[11]d">%[1]s</text>
<text x="%[12]d" y="%[14]d" textLength="%[13]d" fill="#000" fill-opacity=".3">%[2]s</text>
<text x="%[12]d" y="%[15]d" textLength="%[13]d">%[2]s</text>
</g>
</svg>`
width1, width2, baselineOffset := b.CalculateWidths()
width := width1 + width2
baseline := b.Height/2 + baselineOffset
r, g, blue, _ := b.Color.RGBA()
return fmt.Sprintf(svg, b.Caption, b.Text,
r/0x100, g/0x100, blue/0x100,
width, b.Height, width1, width2,
width1/2, width1-12,
width1+width2/2, width2-12,
baseline, baseline-1,
)
}
package main
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"git.sr.ht/~rj/badger/badges"
"github.com/google/subcommands"
)
type countCmd struct {
badgeCmd
}
func (*countCmd) Name() string {
return "count"
}
func (*countCmd) Synopsis() string {
return "Create badge based on a count"
}
func (*countCmd) Usage() string {
return `badger count [options...] <caption> <number>
Creates and saves a badge with requested count.
`
}
func (cmd *countCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 2 {
fmt.Fprintf(os.Stderr, "error: invalid usage: expected 2 argument\n")
return subcommands.ExitUsageError
}
caption := f.Args()[0]
value, err := strconv.ParseUint(f.Args()[1], 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not convert unsigned integer: %s\n", err)
return subcommands.ExitUsageError
}
b := badges.Badge{
Caption: caption,
Text: cmd.Text(value),
Color: badges.InfoColor,
Height: cmd.height,
MinWidth: cmd.minwidth,
}
err = saveBadge(cmd.output, cmd.mimetype, &b)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not save badge: %s\n", err)
return subcommands.ExitFailure
}
cmd.PrintBadgeToStdout(&b)
return subcommands.ExitSuccess
}
func (*countCmd) Text(value uint64) string {
if value < 10000 {
return fmt.Sprintf("%4d", value)
}
if value < 1e5 {
return fmt.Sprintf("%.2fk", float64(value)/1e3)
}
if value < 1e6 {
return fmt.Sprintf("%.1fk", float64(value)/1e3)
}
if value < 1e7 {
return fmt.Sprintf("%.0fk", float64(value)/1e3)
}
if value < 1e8 {
return fmt.Sprintf("%.2fM", float64(value)/1e6)
}
if value < 1e9 {
return fmt.Sprintf("%.1fM", float64(value)/1e6)
}
if value < 1e10 {
return fmt.Sprintf("%.0fM", float64(value)/1e6)
}
if value < 1e11 {
return fmt.Sprintf("%.2fG", float64(value)/1e9)
}
if value < 1e12 {
return fmt.Sprintf("%.1fG", float64(value)/1e9)
}
if value < 1e13 {
return fmt.Sprintf("%.0fG", float64(value)/1e9)
}
return fmt.Sprintf("%1.2e", float64(value))
}
package main
import (
"context"
"flag"
"fmt"
"image/color"
"os"
"strconv"
"strings"
"git.sr.ht/~rj/badger/badges"
"github.com/google/subcommands"
)
type coverageCmd struct {
caption string
badgeCmd
}
func (*coverageCmd) Name() string {
return "coverage"
}
func (*coverageCmd) Synopsis() string {
return "Create badge based on code coverage"
}
func (*coverageCmd) Usage() string {
return `badger coverage [options...] <percentage>
Creates and saves a badge with requested coverage.
`
}
func (cmd *coverageCmd) SetFlags(f *flag.FlagSet) {
cmd.badgeCmd.SetFlags(f)
f.StringVar(&cmd.caption, "caption", "Coverage", "Caption for the badge")
}
func (cmd *coverageCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 1 {
fmt.Fprintf(os.Stderr, "error: invalid usage: expected 1 argument\n")
return subcommands.ExitUsageError
}
value, err := strconv.ParseFloat(stripSuffix(f.Args()[0], "%"), 32)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not convert float: %s\n", err)
return subcommands.ExitUsageError
}
b := badges.Badge{
Caption: cmd.caption,
Text: cmd.Text(value),
Color: cmd.Color(value),
Height: cmd.height,
MinWidth: cmd.minwidth,
}
err = saveBadge(cmd.output, cmd.mimetype, &b)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not save badge: %s\n", err)
return subcommands.ExitFailure
}
cmd.PrintBadgeToStdout(&b)
return subcommands.ExitSuccess
}
func (*coverageCmd) Text(value float64) string {
return fmt.Sprintf("%5.2f%%", value)
}
func (*coverageCmd) Color(value float64) color.Color {
if value < 70 {
return badges.FailColor
} else if value < 90 {
return badges.WarnColor
}
return badges.PassColor
}
func stripSuffix(s string, suffix string) string {
if strings.HasSuffix(s, suffix) {
return s[:len(s)-len(suffix)]
}
return s
}
package encoders
import (
"fmt"
"io"
"git.sr.ht/~rj/badger/badges"
)
type Encoder interface {
Write(io.Writer, *badges.Badge) error
}
func ParseMimeType(value string) (Encoder, error) {
switch value {
case "image/png":
return (*pngEncoder)(nil), nil
case "png":
return (*pngEncoder)(nil), nil
case "image/svg":
return (*svgEncoder)(nil), nil
case "svg":
return (*svgEncoder)(nil), nil
case "text/sgr":
return (*sgrEncoder)(nil), nil
case "sgr":
return (*sgrEncoder)(nil), nil
default:
return nil, fmt.Errorf("mimetype for output image not recognizes: %s", value)
}
}
package encoders
import (
"image/png"
"io"
"git.sr.ht/~rj/badger/badges"
)
type pngEncoder struct{}
func (*pngEncoder) Write(w io.Writer, b *badges.Badge) error {
return png.Encode(w, b.DrawImage())
}
package encoders
import (
"io"
"git.sr.ht/~rj/badger/badges"
)
type sgrEncoder struct{}
func (*sgrEncoder) Write(w io.Writer, b *badges.Badge) error {
_, err := io.WriteString(w, b.DrawSGR())
return err
}
package encoders
import (
"io"
"git.sr.ht/~rj/badger/badges"
)
type svgEncoder struct{}
func (*svgEncoder) Write(w io.Writer, b *badges.Badge) error {
_, err := io.WriteString(w, b.DrawSVG())
return err
}
package jwt
import (
"image/color"
)
// EncodeColor encodes a color as a uint32 for transport.
func EncodeColor(c color.Color) uint32 {
if rgba, ok := c.(color.RGBA); ok {
return uint32(rgba.R) | (uint32(rgba.G) << 8) | (uint32(rgba.B) << 16) | (uint32(rgba.A) << 24)
}
r, g, b, a := c.RGBA()
return (r >> 8) | (g & 0xFF00) | ((b & 0xFF00) << 8) | ((a & 0xFF00) << 16)
}
// DecodeColor decodes a uint32 back to a color.
func DecodeColor(clr uint32) color.RGBA {
r := clr & 0xFF
g := (clr & 0xFF00) >> 8
b := (clr & 0xFF0000) >> 16
a := (clr & 0xFF000000) >> 24
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)}
}
package jwt
import (
"crypto/rsa"
"time"
"git.sr.ht/~rj/badger/badges"
jwt "github.com/dgrijalva/jwt-go"
)
// Claims are the JWT claims required to transmit and authorize the badge.
type Claims struct {
Name string `json:"name"`
Caption string `json:"caption"`
Text string `json:"text"`
Color uint32 `json:"color"`
Height int `json:"height,omitempty"`
MinWidth int `json:"minwidth,omitempty"`
rsa.PublicKey
jwt.StandardClaims
}
// Valid returns an error if the claims are invalid.
func (c *Claims) Valid() error {
if c.Name == "" {
return jwt.NewValidationError("JWT does not contain a valid name",
jwt.ValidationErrorClaimsInvalid)
}
return c.StandardClaims.Valid()
}
// CreateTokenRSA creates a JWT with the badge information, and authorizing
// the badge to be saved with the public key and name.
func CreateTokenRSA(key *rsa.PrivateKey, name string, b *badges.Badge) (string, error) {
// Create the Claims
claims := &Claims{
Name: name,
Caption: b.Caption,
Text: b.Text,
Color: EncodeColor(b.Color),
Height: b.Height,
MinWidth: b.MinWidth,
PublicKey: key.PublicKey,
StandardClaims: jwt.StandardClaims{
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Unix() + 60,
Issuer: "git.sr.ht/~rj/badger",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
ss, err := token.SignedString(key)
if err != nil {
return "", err
}
return ss, nil
}
// ParseToken decodes a JWT to extract the badge and authorization.
func ParseToken(token string) (*rsa.PublicKey, string, *badges.Badge, error) {
var claims Claims
_, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, &jwt.ValidationError{Errors: jwt.ValidationErrorClaimsInvalid}
}
return &claims.PublicKey, nil
})
if err != nil {
return nil, "", nil, err
}
badge := &badges.Badge{
Caption: claims.Caption,
Text: claims.Text,
Color: DecodeColor(claims.Color),
Height: claims.Height,
MinWidth: claims.MinWidth,
}
badge.ApplyDefaults()
return &claims.PublicKey, claims.Name, badge, nil
}
package jwt
import (
"crypto/rsa"
"io"
"io/ioutil"
"os"
"golang.org/x/crypto/ssh"
)
// LoadKey loads an RSA private key from a file.
func LoadKey(filename string) (*rsa.PrivateKey, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
return ReadKey(file)
}
// ReadKey reads an RSA private key from a reader.
func ReadKey(r io.Reader) (*rsa.PrivateKey, error) {
data, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
pk, err := ssh.ParseRawPrivateKey(data)
if err != nil {
return nil, err
}
return pk.(*rsa.PrivateKey), nil
}
package main
import (
"context"
"flag"
"os"
"git.sr.ht/~rj/badger/badges"
"git.sr.ht/~rj/badger/encoders"
"github.com/google/subcommands"
)
func main() {
subcommands.Register(subcommands.HelpCommand(), "")
subcommands.Register(subcommands.FlagsCommand(), "")
subcommands.Register(subcommands.CommandsCommand(), "")
subcommands.Register(&coverageCmd{}, "")
subcommands.Register(&countCmd{}, "")
subcommands.Register(&passfailCmd{}, "")
subcommands.Register(&textCmd{}, "")
subcommands.Register(&versionCmd{}, "")
flag.Parse()
ctx := context.Background()
os.Exit(int(subcommands.Execute(ctx)))
}
func saveBadge(filename string, mimetype string, b *badges.Badge) error {
encoder, err := encoders.ParseMimeType(mimetype)
if err != nil {
return err
}
if filename == "-" {
return encoder.Write(os.Stdout, b)
}
file, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer file.Close()
return encoder.Write(file, b)
}
package main
import (
"context"
"flag"
"fmt"
"image/color"
"os"
"strconv"
"git.sr.ht/~rj/badger/badges"
"github.com/google/subcommands"
)
type passfailCmd struct {
badgeCmd
}
func (*passfailCmd) Name() string {
return "passfail"
}
func (*passfailCmd) Synopsis() string {
return "Create badge based on pass/fail"
}
func (*passfailCmd) Usage() string {
return `badger passfail [options...] <caption> <flag>
Creates and saves a badge with requested caption and status.
`
}
func (cmd *passfailCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 2 {
fmt.Fprintf(os.Stderr, "error: invalid usage: expected 2 arguments\n")
return subcommands.ExitUsageError
}
caption := f.Args()[0]
value, err := strconv.ParseBool(f.Args()[1])
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not convert bool: %s\n", err)
return subcommands.ExitUsageError
}
b := badges.Badge{
Caption: caption,
Text: cmd.Text(value),
Color: cmd.Color(value),
Height: cmd.height,
MinWidth: cmd.minwidth,
}
err = saveBadge(cmd.output, cmd.mimetype, &b)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not save badge: %s\n", err)
return subcommands.ExitFailure
}
cmd.PrintBadgeToStdout(&b)
return subcommands.ExitSuccess
}
func (*passfailCmd) Text(value bool) string {
if value {
return "passed"
}
return "failed"
}
func (*passfailCmd) Color(value bool) color.Color {
if value {
return badges.PassColor
}
return badges.FailColor
}
package main
import (
"context"
"flag"
"fmt"
"os"
"git.sr.ht/~rj/badger/badges"
"github.com/google/subcommands"
)
type badgeCmd struct {
output string
mimetype string
height int
minwidth int
quiet bool
}
func (p *badgeCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&p.output, "o", "-", "Name of the output file")
f.StringVar(&p.mimetype, "mimetype", "image/svg", "Format for the output image")
f.IntVar(&p.height, "height", 20, "Height of the badge")
f.IntVar(&p.minwidth, "minwidth", 0, "Minimum width of the badge")
f.BoolVar(&p.quiet, "quiet", false, "Suppress writing text badge to stdout")
}
func (p *badgeCmd) PrintBadgeToStdout(b *badges.Badge) {
if !p.quiet && p.output != "-" {
fmt.Println(b.DrawSGR())
}
}
type textCmd struct {
badgeCmd
}
func (*textCmd) Name() string {
return "text"
}
func (*textCmd) Synopsis() string {
return "Create badge based on fixed text"
}
func (*textCmd) Usage() string {
return `badger text [options...] <caption> <text>
Creates and saves a badge with requested caption and text.
`
}
func (cmd *textCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 2 {
fmt.Fprintf(os.Stderr, "error: invalid usage: expected 2 arguments\n")
return subcommands.ExitUsageError
}
caption := f.Args()[0]
value := f.Args()[1]
b := badges.Badge{
Caption: caption,
Text: value,
Color: badges.InfoColor,
Height: cmd.height,
MinWidth: cmd.minwidth,
}
err := saveBadge(cmd.output, cmd.mimetype, &b)
if err != nil {
fmt.Fprintf(os.Stderr, "error: could not save badge: %s\n", err)
return subcommands.ExitFailure
}
cmd.PrintBadgeToStdout(&b)
return subcommands.ExitSuccess
}
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/google/subcommands"
)
var (
version = "development"
)
type versionCmd struct {
}
func (*versionCmd) Name() string {
return "version"
}
func (*versionCmd) SetFlags(f *flag.FlagSet) {
}
func (*versionCmd) Synopsis() string {
return "Display version information"
}
func (*versionCmd) Usage() string {
return `badger version
Display version information.
`
}
func (cmd *versionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 0 {
fmt.Fprintf(os.Stderr, "error: invalid usage: expected 0 arguments\n")
return subcommands.ExitUsageError
}
fmt.Printf("badger (%s)\n", version)
return subcommands.ExitSuccess
}