Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

svg lettericons #55

Merged
merged 6 commits into from
Feb 28, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ test_all: build test test_bench
test:
go test -v github.com/mat/besticon/ico
go test -v github.com/mat/besticon/besticon
go test -v github.com/mat/besticon/besticon/iconserver
go test -v github.com/mat/besticon/lettericon
go test -v github.com/mat/besticon/colorfinder

Expand Down
30 changes: 25 additions & 5 deletions besticon/iconserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ func iconHandler(w http.ResponseWriter, r *http.Request) {
}
}

redirectPath := lettericon.IconPath(letter, fmt.Sprintf("%d", sizeRange.Perfect), iconColor)
// We support both PNG and SVG fallback. Only return SVG if requested.
format := "png"
if includesString(finder.FormatsAllowed, "svg") {
format = "svg"
}
redirectPath := lettericon.IconPath(letter, fmt.Sprintf("%d", sizeRange.Perfect), iconColor, format)
redirectWithCacheControl(w, r, redirectPath)
}

Expand Down Expand Up @@ -155,15 +160,20 @@ func alliconsHandler(w http.ResponseWriter, r *http.Request) {
}

func lettericonHandler(w http.ResponseWriter, r *http.Request) {
charParam, col, size := lettericon.ParseIconPath(r.URL.Path)
if charParam == "" || col == nil || size <= 0 {
charParam, col, size, format := lettericon.ParseIconPath(r.URL.Path)
if charParam == "" || col == nil || size <= 0 || format == "" {
writeAPIError(w, 400, errors.New("wrong format for lettericons/ path, must look like lettericons/M-144-EFC25D.png"))
return
}

w.Header().Add(contentType, imagePNG)
if format == "svg" {
w.Header().Add(contentType, imageSVG)
lettericon.RenderSVG(charParam, col, w)
} else {
w.Header().Add(contentType, imagePNG)
lettericon.RenderPNG(charParam, col, size, w)
}
addCacheControl(w, oneYear)
lettericon.Render(charParam, col, size, w)
}

func writeAPIError(w http.ResponseWriter, httpStatus int, e error) {
Expand Down Expand Up @@ -198,6 +208,7 @@ const (
contentType = "Content-Type"
applicationJSON = "application/json"
imagePNG = "image/png"
imageSVG = "image/svg+xml"
)

func renderJSONResponse(w http.ResponseWriter, httpStatus int, data interface{}) {
Expand Down Expand Up @@ -431,3 +442,12 @@ func getenvOrFallback(key string, fallbackValue string) string {
}
return fallbackValue
}

func includesString(arr []string, str string) bool {
for _, e := range arr {
if e == str {
return true
}
}
return false
}
30 changes: 29 additions & 1 deletion besticon/iconserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ func TestGet404IconWithInvalidFallbackColor(t *testing.T) {
assertStringEquals(t, "/lettericons/H-32.png", w.Header().Get("Location"))
}

func TestGetIconWithSVG(t *testing.T) {
req, err := http.NewRequest("GET", "/icons?size=32&url=httpbin.org/status/404&formats=svg", nil)
if err != nil {
log.Fatal(err)
}

w := httptest.NewRecorder()
iconHandler(w, req)

assertStringEquals(t, "302", fmt.Sprintf("%d", w.Code))
assertStringEquals(t, "/lettericons/H.svg", w.Header().Get("Location"))
}

func TestGetAllIcons(t *testing.T) {
req, err := http.NewRequest("GET", "/allicons.json?url=apple.com", nil)
if err != nil {
Expand Down Expand Up @@ -173,7 +186,7 @@ func TestGetPopular(t *testing.T) {
assertStringContains(t, w.Body.String(), `github.com`)
}

func TestGetLetterIcon(t *testing.T) {
func TestGetLetterIconPNG(t *testing.T) {
req, err := http.NewRequest("GET", "/lettericons/M-144-EFC25D.png", nil)
if err != nil {
t.Fatal(err)
Expand All @@ -188,6 +201,21 @@ func TestGetLetterIcon(t *testing.T) {
assertIntegerInInterval(t, 1500, 1800, w.Body.Len())
}

func TestGetLetterIconSVG(t *testing.T) {
req, err := http.NewRequest("GET", "/lettericons/M-144-EFC25D.svg", nil)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even want to allow for the size to be in the path for .svg? I'm thinking the fact the we allow it is kind of ok by Postel's law on the one hand but could lead to misunderstanding one the other (we're not using this).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let's turn off support for size entirely. We're not generating those with SVG so no need to parse them. Give me a sec here

if err != nil {
t.Fatal(err)
}

w := httptest.NewRecorder()
lettericonHandler(w, req)

assertStringEquals(t, "200", fmt.Sprintf("%d", w.Code))
assertStringEquals(t, "image/svg+xml", w.Header().Get("Content-Type"))
assertStringEquals(t, "max-age=31536000", w.Header().Get("Cache-Control"))
assertStringContains(t, w.Body.String(), `<svg`)
}

func TestGetBadLetterIconPath(t *testing.T) {
req, err := http.NewRequest("GET", "/lettericons/--120.png", nil)
if err != nil {
Expand Down
78 changes: 70 additions & 8 deletions lettericon/lettericon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package lettericon

import (
"bufio"
"bytes"
"encoding/xml"
"errors"
"fmt"
"image"
Expand All @@ -13,6 +15,7 @@ import (
"math"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"

Expand All @@ -30,7 +33,7 @@ const dpi = 72
const fontSizeFactor = 0.6180340 // (by taste)
const yOffsetFactor = 102.0 / 1024.0 // (by trial and error) :-)

func Render(letter string, bgColor color.Color, width int, out io.Writer) error {
func RenderPNG(letter string, bgColor color.Color, width int, out io.Writer) error {
fg := pickForegroundColor(bgColor)

rgba := image.NewRGBA(image.Rect(0, 0, width, width))
Expand Down Expand Up @@ -179,32 +182,52 @@ func ColorFromHex(hex string) (*color.RGBA, error) {
return &col, nil
}

func IconPath(letter string, size string, colr *color.RGBA) string {
func IconPath(letter string, size string, colr *color.RGBA, format string) string {
var parts []string

// letter
if letter == "" {
letter = " "
} else {
letter = strings.ToUpper(letter)
}
parts = append(parts, letter)

// size (maybe)
if format == "png" {
parts = append(parts, size)
}

// colr (maybe)
if colr != nil {
return fmt.Sprintf("/lettericons/%s-%s-%s.png", letter, size, colorfinder.ColorToHex(*colr))
parts = append(parts, colorfinder.ColorToHex(*colr))
}
return fmt.Sprintf("/lettericons/%s-%s.png", letter, size)

return fmt.Sprintf("/lettericons/%s.%s", strings.Join(parts, "-"), format)
}

const defaultIconSize = 144

// TODO: Sync with besticon.MaxIconSize ?
const maxIconSize = 256

// path is like: lettericons/M-144-EFC25D.png
func ParseIconPath(fullpath string) (string, *color.RGBA, int) {
func ParseIconPath(fullpath string) (string, *color.RGBA, int, string) {
fullpath = percentDecode(fullpath)

_, filename := path.Split(fullpath)
filename = strings.TrimSuffix(filename, ".png")

// what is the format?
format := filepath.Ext(filename)
if !(format == ".png" || format == ".svg") {
return "", nil, -1, ""
}
filename = strings.TrimSuffix(filename, format)
format = format[1:] // remove period

params := strings.Split(filename, "-")
if len(params) < 1 || len(params[0]) < 1 {
return "", nil, -1
return "", nil, -1, ""
}

charParam := firstRune(params[0])
Expand All @@ -230,7 +253,7 @@ func ParseIconPath(fullpath string) (string, *color.RGBA, int) {
col = DefaultBackgroundColor
}

return charParam, col, size
return charParam, col, size, format
}

func MainLetterFromURL(URL string) string {
Expand Down Expand Up @@ -281,6 +304,45 @@ func percentDecode(p string) string {
return u.Path
}

const svgTemplate = `
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="100" height="100" fill="$BG_COLOR"/>
<text x="50%" y="50%" dy="0.10em" font-family="Helvetica Neue, Helvetica, sans-serif" font-size="75" dominant-baseline="middle" text-anchor="middle" fill="$FG_COLOR">$LETTER</text>
</svg>
`

// RenderSVG writes an SVG lettericon for this letter and color
func RenderSVG(letter string, bgColor color.Color, out io.Writer) error {
// xml escape letter
var buf bytes.Buffer
err := xml.EscapeText(&buf, []byte(letter))
if err != nil {
return err
}

// vars
vars := map[string]string{
"$BG_COLOR": ColorToHex(bgColor),
"$FG_COLOR": ColorToHex(pickForegroundColor(bgColor)),
"$LETTER": buf.String(),
Comment on lines +340 to +343
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like the lightweight templating approach for this👍

}

// render SVG by replacing vars in template
svg := strings.TrimSpace(svgTemplate) + "\n"
for k, v := range vars {
svg = strings.ReplaceAll(svg, k, v)
}

_, err = io.WriteString(out, svg)
return err
}

// ColorToHex returns the #rrggbb hex string for a color
func ColorToHex(c color.Color) string {
r, g, b, _ := c.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r&0xff, g&0xff, b&0xff)
}

var fnt *truetype.Font

var DefaultBackgroundColor *color.RGBA
Expand Down
2 changes: 1 addition & 1 deletion lettericon/lettericon/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func main() {
os.Exit(1)
}

err = lettericon.Render(*letter, col, *width, f)
err = lettericon.RenderPNG(*letter, col, *width, f)
if err != nil {
os.Exit(1)
}
Expand Down
Loading