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

Refactor the initials package in order to be able to accept new implementations #3819

Merged
merged 5 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
88 changes: 88 additions & 0 deletions pkg/avatar/initials_convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package avatar

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"

"github.com/cozy/cozy-stack/pkg/logger"
)

var (
ErrInvalidCmd = fmt.Errorf("invalid cmd argument")
)

// PNGInitials create PNG avatars with initials in it.
//
// This implementation is based on the `convert` binary.
type PNGInitials struct {
tempDir string
env []string
cmd string
}

// NewPNGInitials instantiate a new [PNGInitials].
func NewPNGInitials(cmd string) (*PNGInitials, error) {
if cmd == "" {
return nil, ErrInvalidCmd
}

tempDir, err := os.MkdirTemp("", "magick")
if err != nil {
return nil, fmt.Errorf("failed to create the tempdir: %w", err)
}

envTempDir := fmt.Sprintf("MAGICK_TEMPORARY_PATH=%s", tempDir)
env := []string{envTempDir}

return &PNGInitials{tempDir, env, cmd}, nil
}

// ContentType return the generated avatar content-type.
func (a *PNGInitials) ContentType() string {
return "image/png"
}

// Generate will create a new avatar with the given initials and color.
func (a *PNGInitials) Generate(ctx context.Context, initials, color string) ([]byte, error) {
// convert -size 128x128 null: -fill blue -draw 'circle 64,64 0,64' -fill white -font Lato-Regular
// -pointsize 64 -gravity center -annotate "+0,+0" "AM" foo.png
args := []string{
"-limit", "Memory", "1GB",
"-limit", "Map", "1GB",
// Use a transparent background
"-size", "128x128",
"null:",
// Add a cicle of color
"-fill", color,
"-draw", "circle 64,64 0,64",
// Add the initials
"-fill", "white",
"-font", "Lato-Regular",
"-pointsize", "64",
"-gravity", "center",
"-annotate", "+0,+0",
initials,
// Use the colorspace recommended for web, sRGB
"-colorspace", "sRGB",
// Send the output on stdout, in PNG format
"png:-",
}

var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, a.cmd, args...)
cmd.Env = a.env
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
logger.WithNamespace("initials").
WithField("stderr", stderr.String()).
WithField("initials", initials).
WithField("color", color).
Errorf("imagemagick failed: %s", err)
return nil, fmt.Errorf("failed to run the cmd %q: %w", a.cmd, err)
}
return stdout.Bytes(), nil
}
46 changes: 46 additions & 0 deletions pkg/avatar/initials_convert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package avatar

import (
"bytes"
"context"
"image/png"
"os"
"testing"

"github.com/stretchr/testify/require"
)

func TestInitialsPNG(t *testing.T) {
if testing.Short() {
t.Skipf("this test require the \"convert\" binary, skip it due to the \"--short\" flag")
}

client, err := NewPNGInitials("convert")
require.NoError(t, err)

ctx := context.Background()

rawRes, err := client.Generate(ctx, "JD", "#FF7F1B")
require.NoError(t, err)

rawExpected, err := os.ReadFile("./testdata/initials-convert.png")
require.NoError(t, err)

// Due to the compression algorithm we can't compare the bytes
// as they change for each generation. The only solution is to decode
// the image and check pixel by pixel.
// This also allow to ensure that the end result is exactly the same.
resImg, err := png.Decode(bytes.NewReader(rawRes))
require.NoError(t, err)

expectImg, err := png.Decode(bytes.NewReader(rawExpected))
require.NoError(t, err)

require.Equal(t, expectImg.Bounds(), resImg.Bounds(), "images doesn't have the same size")

for x := 0; x < resImg.Bounds().Max.X; x++ {
for y := 0; y < resImg.Bounds().Max.Y; y++ {
require.Equal(t, expectImg.At(x, y), resImg.At(x, y))
}
}
}
135 changes: 135 additions & 0 deletions pkg/avatar/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package avatar

import (
"context"
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"

"github.com/cozy/cozy-stack/pkg/cache"
)

// Options can be used to give options for the generated image
type Options int

const (
cacheTTL = 30 * 24 * time.Hour // 1 month
contentType = "image/png"

// GreyBackground is an option to force a grey background
GreyBackground Options = 1 + iota
)

// Initials is able to generate initial avatar.
type Initials interface {
// Generate will create a new avatar with the given initials and color.
Generate(ctx context.Context, initials, color string) ([]byte, error)
ContentType() string
}

// Service handle all the interactions with the initials images.
type Service struct {
cache cache.Cache
initials Initials
}

// NewService instantiate a new [Service].
func NewService(cache cache.Cache, cmd string) (*Service, error) {
if cmd == "" {
cmd = "convert"
}

initials, err := NewPNGInitials(cmd)
if err != nil {
return nil, fmt.Errorf("failed to create the PNG initials implem: %w", err)
}

return &Service{cache, initials}, nil
}

// GenerateInitials an image with the initials for the given name (and the
// content-type to use for the HTTP response).
func (s *Service) GenerateInitials(publicName string, opts ...Options) ([]byte, string, error) {
name := strings.TrimSpace(publicName)
info := extractInfo(name)
for _, opt := range opts {
if opt == GreyBackground {
info.color = charcoalGrey
}
}

key := "initials:" + info.initials + info.color
if bytes, ok := s.cache.Get(key); ok {
return bytes, contentType, nil
}

bytes, err := s.initials.Generate(context.TODO(), info.initials, info.color)
if err != nil {
return nil, "", err
}
s.cache.Set(key, bytes, cacheTTL)
return bytes, s.initials.ContentType(), nil
}

// See https://github.com/cozy/cozy-ui/blob/master/react/Avatar/index.jsx#L9-L26
// and https://docs.cozy.io/cozy-ui/styleguide/section-settings.html#kssref-settings-colors
var colors = []string{
"#1FA8F1",
"#FD7461",
"#FC6D00",
"#F52D2D",
"#FF962F",
"#FF7F1B",
"#6984CE",
"#7F6BEE",
"#B449E7",
"#40DE8E",
"#0DCBCF",
"#35CE68",
"#3DA67E",
"#C2ADF4",
"#FFC644",
"#FC4C83",
}

var charcoalGrey = "#32363F"

type info struct {
initials string
color string
}

func extractInfo(name string) info {
initials := strings.ToUpper(getInitials(name))
color := getColor(name)
return info{initials: initials, color: color}
}

func getInitials(name string) string {
parts := strings.Split(name, " ")
initials := make([]rune, 0, len(parts))
for _, part := range parts {
r, size := utf8.DecodeRuneInString(part)
if size > 0 && unicode.IsLetter(r) {
initials = append(initials, r)
}
}
switch len(initials) {
case 0:
return "?"
case 1:
return string(initials)
default:
return string(initials[0]) + string(initials[len(initials)-1])
}
}

func getColor(name string) string {
sum := 0
for i := 0; i < len(name); i++ {
sum += int(name[i])
}
return colors[sum%len(colors)]
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package initials
package avatar

import (
"math/rand"
Expand Down
Binary file added pkg/avatar/testdata/initials-convert.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion pkg/config/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"text/template"
"time"

"github.com/cozy/cozy-stack/pkg/avatar"
"github.com/cozy/cozy-stack/pkg/cache"
build "github.com/cozy/cozy-stack/pkg/config"
"github.com/cozy/cozy-stack/pkg/keymgmt"
Expand Down Expand Up @@ -112,6 +113,7 @@ type Config struct {

RemoteAssets map[string]string

Avatars *avatar.Service
Fs Fs
CouchDB CouchDB
Jobs Jobs
Expand Down Expand Up @@ -378,6 +380,11 @@ func GetConfig() *Config {
return config
}

// Avatars return the configured initials service.
func Avatars() *avatar.Service {
return config.Avatars
}

// GetVault returns the configured instance of Vault
func GetVault() *Vault {
if vault == nil {
Expand Down Expand Up @@ -727,6 +734,12 @@ func UseViper(v *viper.Viper) error {
}
}

cacheStorage := cache.New(cacheRedis.Client())
avatars, err := avatar.NewService(cacheStorage, v.GetString("jobs.imagemagick_convert_cmd"))
if err != nil {
return fmt.Errorf("failed to create the avatar service: %w", err)
}

config = &Config{
Host: v.GetString("host"),
Port: v.GetInt("port"),
Expand All @@ -751,6 +764,7 @@ func UseViper(v *viper.Viper) error {
CredentialsEncryptorKey: v.GetString("vault.credentials_encryptor_key"),
CredentialsDecryptorKey: v.GetString("vault.credentials_decryptor_key"),

Avatars: avatars,
Fs: Fs{
URL: fsURL,
Transport: fsClient.Transport,
Expand Down Expand Up @@ -799,7 +813,7 @@ func UseViper(v *viper.Viper) error {
RateLimitingStorage: rateLimitingRedis,
OauthStateStorage: oauthStateRedis,
Realtime: realtimeRedis,
CacheStorage: cache.New(cacheRedis.Client()),
CacheStorage: cacheStorage,
Logger: logger.Options{
Level: v.GetString("log.level"),
Syslog: v.GetBool("log.syslog"),
Expand Down
Loading