From c93c9a58af30d858b496ff86a0a96261fcc4f28c Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Wed, 12 Jan 2022 15:32:31 +0100 Subject: [PATCH] Add GitHub alike identicons --- README.adoc | 16 ++-- colors.go | 90 ++++++++++++++++++ idicon.go | 87 +++++++++++------ idicon_test.go | 47 ++++++++- .../1a79a4d60de6718e8e5b326e338ae533_gh.png | Bin 0 -> 242 bytes 5 files changed, 200 insertions(+), 40 deletions(-) create mode 100644 colors.go create mode 100644 testdata/1a79a4d60de6718e8e5b326e338ae533_gh.png diff --git a/README.adoc b/README.adoc index 2d1ff95..6095a17 100644 --- a/README.adoc +++ b/README.adoc @@ -13,21 +13,19 @@ curl http://localhost:8000/avatar/23463b99b62a72f26ed677cc556c44e8?s=100&c=v2 Instead of requesting identicons for MD5 hashes of usernames or mail addresses, it is possible to use plain username and mail address. The will result in the same generated identicon. Use request query parameter `s` to request images with specified size. Default value is 80px. The size is limited to a maximum value of 512px. -Query parameter `c` will set color scheme. Available values are `v1` and `v2`. -=== Server settings +Query parameter `c` will set color scheme. Available values are `v1`, `v2` and `gh`. +The latter resembles the color scheme used by GitHub. -You can use `COLORSCHEME` in environment to define the default color scheme to be used. Fallback value will be `v2`. +The request query parameter `d` can be used to request GitHub like patterns by setting the value to `github`. -== Development and local start +=== Configuration -A *Golang* setup is required for development. File `idicon.go` contains the source code. Type +Configuration is available by using environment variables. -.... -$ go run -.... +You can use `COLORSCHEME` to define the default color scheme to be used. Fallback value will be `v2`. -to run the application on port 8000. +The `PATTERN` environment variable is available to define GitHub like patterns as default by using `github`. === Docker build diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..04a7d2d --- /dev/null +++ b/colors.go @@ -0,0 +1,90 @@ +package main + +import "image/color" + +func colorV1(hash [16]byte) color.RGBA { + r := 32 + (hash[0]%16)/2<<4 + g := 32 + (hash[2]%16)/2<<4 + b := 32 + (hash[len(hash)-1]%16)/2<<4 + + if r > g && r > b { + r += 48 + } else if g > r && g > b { + g += 48 + } else if b > r && b > g { + b += 48 + } + return color.RGBA{r, g, b, 255} +} + +func colorV2(hash [16]byte) color.RGBA { + var palette = []color.RGBA{ + {0x3c, 0x38, 0x36, 0xff}, + {0xcc, 0x24, 0x1d, 0xff}, + {0x98, 0x97, 0x1a, 0xff}, + {0xd7, 0x99, 0x21, 0xff}, + {0x45, 0x85, 0x88, 0xff}, + {0xb1, 0x62, 0x86, 0xff}, + {0x68, 0x9d, 0x6a, 0xff}, + {0xa8, 0x99, 0x84, 0xff}, + } + return palette[hash[15]%8] +} + +func colorGh(hash [16]byte) color.RGBA { + h1 := (uint16(hash[12]) & 0x0f) << 8 + h2 := uint16(hash[13]) + + h := uint32(h1 | h2) + s := uint32(hash[14]) + l := uint32(hash[15]) + + return hslToRgba( + remap(h, 0, 4096, 0, 360), + 65.0-remap(s, 0, 255, 0, 20), + 75.0-remap(l, 0, 255, 0, 20), + ) +} + +// http://www.w3.org/TR/css3-color/#hsl-color +func hslToRgba(hue float32, sat float32, lum float32) color.RGBA { + hue = hue / 360.0 + sat = sat / 100.0 + lum = lum / 100.0 + + t2 := lum + sat - (lum * sat) + if lum <= 0.5 { + t2 = lum * (sat + 1.0) + } + + t1 := lum*2.0 - t2 + + return color.RGBA{ + uint8(hueToRgb(t1, t2, hue+1.0/3.0) * 255), + uint8(hueToRgb(t1, t2, hue) * 255), + uint8(hueToRgb(t1, t2, hue-1.0/3.0) * 255), + 0xff, + } +} + +func hueToRgb(t1 float32, t2 float32, hue float32) float32 { + if hue < 0.0 { + hue = hue + 1.0 + } else if hue >= 1.0 { + hue = hue - 1.0 + } + + if hue < 1.0/6.0 { + return t1 + (t2-t1)*6.0*hue + } + + if hue < 1.0/2.0 { + return t2 + } + + if hue < 2.0/3.0 { + return t1 + (t2-t1)*(2.0/3.0-hue)*6.0 + } + + return t1 +} diff --git a/idicon.go b/idicon.go index 69cf797..c183464 100644 --- a/idicon.go +++ b/idicon.go @@ -3,7 +3,6 @@ package main import ( "crypto/md5" "encoding/hex" - "github.com/gorilla/mux" "image" "image/color" "image/draw" @@ -14,35 +13,49 @@ import ( "regexp" "strconv" "strings" + + "github.com/gorilla/mux" ) -func colorV1(hash [16]byte) color.RGBA { - r := 32 + (hash[0]%16)/2<<4 - g := 32 + (hash[2]%16)/2<<4 - b := 32 + (hash[len(hash)-1]%16)/2<<4 - - if r > g && r > b { - r += 48 - } else if g > r && g > b { - g += 48 - } else if b > r && b > g { - b += 48 - } - return color.RGBA{r, g, b, 255} +// https://processing.org/reference/map_.html +func remap(value uint32, vmin uint32, vmax uint32, dmin uint32, dmax uint32) float32 { + return float32((value-vmin)*(dmax-dmin)) / float32((vmax-vmin)+dmin) } -func colorV2(hash [16]byte) color.RGBA { - var palette = []color.RGBA{ - {0x3c, 0x38, 0x36, 0xff}, - {0xcc, 0x24, 0x1d, 0xff}, - {0x98, 0x97, 0x1a, 0xff}, - {0xd7, 0x99, 0x21, 0xff}, - {0x45, 0x85, 0x88, 0xff}, - {0xb1, 0x62, 0x86, 0xff}, - {0x68, 0x9d, 0x6a, 0xff}, - {0xa8, 0x99, 0x84, 0xff}, +func nibbles(hash [16]byte) []byte { + nibbles := make([]byte, 32) + + for i := 0; i <= 15; i++ { + nibbles[i*2+1] = hash[i] & 0x0f + nibbles[i*2] = hash[i] & 0xf0 >> 4 } - return palette[hash[15]%8] + + return nibbles +} + +// Based on https://github.com/dgraham/identicon +func genGhIcon(id string, size int, f func([16]byte) color.RGBA) *image.NRGBA { + if size > 512 { + size = 512 + } + + blocks := 5 + + hash := hashBytes(id) + nibbles := nibbles(hash) + data := make([]bool, blocks*blocks) + + for x := 0; x < blocks; x++ { + for y := 0; y < blocks; y++ { + ni := x + blocks*(blocks-y-1) + if x+blocks*y > 2*blocks { + di := (x + blocks*y) - 2*blocks + data[di] = nibbles[ni]%2 == 0 + } + } + } + + return drawImage(mirrorData(data, blocks), blocks, size, f(hash)) } func genIdIcon(id string, size int, f func([16]byte) color.RGBA) *image.NRGBA { @@ -123,16 +136,30 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) { colorScheme = os.Getenv("COLORSCHEME") } - w.Header().Add("Content-Type", "image/png") - if colorScheme == "v1" { - err = png.Encode(w, genIdIcon(id, size, colorV1)) - } else { - err = png.Encode(w, genIdIcon(id, size, colorV2)) + pattern := r.URL.Query().Get("d") + if pattern == "" { + pattern = os.Getenv("PATTERN") } + + w.Header().Add("Content-Type", "image/png") + cFunc := colorV2 + if colorScheme == "v1" { + cFunc = colorV1 + } else if colorScheme == "gh" { + cFunc = colorGh + } + + if pattern == "github" { + err = png.Encode(w, genGhIcon(id, size, cFunc)) + } else { + err = png.Encode(w, genIdIcon(id, size, cFunc)) + } + } func main() { router := mux.NewRouter() router.HandleFunc("/avatar/{id}", RequestHandler) + log.Println("Starting ...") log.Fatal(http.ListenAndServe(":8000", router)) } diff --git a/idicon_test.go b/idicon_test.go index ce9bc15..055be17 100644 --- a/idicon_test.go +++ b/idicon_test.go @@ -3,12 +3,13 @@ package main import ( "bytes" _ "embed" - "github.com/gorilla/mux" "image/png" "net/http" "net/http/httptest" "reflect" "testing" + + "github.com/gorilla/mux" ) //go:embed testdata/1a79a4d60de6718e8e5b326e338ae533_v1.png @@ -17,6 +18,9 @@ var v1 []byte //go:embed testdata/1a79a4d60de6718e8e5b326e338ae533_v2.png var v2 []byte +//go:embed testdata/1a79a4d60de6718e8e5b326e338ae533_gh.png +var gh []byte + func testRouter() *mux.Router { router := mux.NewRouter() router.HandleFunc("/avatar/{id}", RequestHandler) @@ -65,6 +69,20 @@ func TestCorrectResponseForV2ColorScheme(t *testing.T) { } } +func TestCorrectResponseForGHColorSchemeAndPattern(t *testing.T) { + req, err := http.NewRequest("GET", "/avatar/1a79a4d60de6718e8e5b326e338ae533?c=gh&d=github", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + testRouter().ServeHTTP(rr, req) + + if !reflect.DeepEqual(rr.Body.Bytes(), gh) { + t.Errorf("returned image does not match expected image") + } +} + func TestIgnoreCase(t *testing.T) { w1 := bytes.NewBuffer([]byte{}) png.Encode(w1, genIdIcon("example", 80, colorV1)) @@ -89,3 +107,30 @@ func TestStringMatchesHash(t *testing.T) { t.Errorf("resulting images do not match") } } + +func TestHSLtoRGB(t *testing.T) { + red := hslToRgba(0, 100, 50) + if red.R != 255 || red.G != 0 || red.B != 0 { + t.Errorf("Color red not as required") + } + + green := hslToRgba(120, 100, 50) + if green.R != 0 || green.G != 255 || green.B != 0 { + t.Errorf("Color green not as required") + } + + blue := hslToRgba(240, 100, 50) + if blue.R != 0 || blue.G != 0 || blue.B != 255 { + t.Errorf("Color blue not as required") + } +} + +func TestShouldCreateNibbles(t *testing.T) { + hash := [16]byte{} + hash[0] = 0x12 + nibbles := nibbles(hash) + + if nibbles[0] != 0x01 || nibbles[1] != 02 { + t.Errorf("Nibbles not extracted as expected") + } +} diff --git a/testdata/1a79a4d60de6718e8e5b326e338ae533_gh.png b/testdata/1a79a4d60de6718e8e5b326e338ae533_gh.png new file mode 100644 index 0000000000000000000000000000000000000000..f8262c2e140a19319ba673bee520250fe2016a28 GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^0U*r51SA=YQ-1-eot`d^Ar*7p+&akHY#_jTQKd+D zrIPH@9VJt=cI;x2VmNup>)4t3