From d931c8f07a371124e599cd6b6ab9f6a6586640ac Mon Sep 17 00:00:00 2001 From: Peltoche Date: Mon, 6 Mar 2023 10:41:05 +0100 Subject: [PATCH 1/5] Move `/pkg/initials` function into the service format This is a required work to propose several implementation for the avatar generation --- pkg/config/config/config.go | 12 +++++++++++- pkg/initials/initials.go | 37 +++++++++++++++++++++++-------------- web/public/public.go | 4 ++-- web/sharings/sharings.go | 3 ++- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/pkg/config/config/config.go b/pkg/config/config/config.go index c8a4ac5cf72..78a7f2fff7e 100644 --- a/pkg/config/config/config.go +++ b/pkg/config/config/config.go @@ -22,6 +22,7 @@ import ( "github.com/cozy/cozy-stack/pkg/cache" build "github.com/cozy/cozy-stack/pkg/config" + "github.com/cozy/cozy-stack/pkg/initials" "github.com/cozy/cozy-stack/pkg/keymgmt" "github.com/cozy/cozy-stack/pkg/lock" "github.com/cozy/cozy-stack/pkg/logger" @@ -112,6 +113,7 @@ type Config struct { RemoteAssets map[string]string + Initials *initials.Service Fs Fs CouchDB CouchDB Jobs Jobs @@ -378,6 +380,11 @@ func GetConfig() *Config { return config } +// Initials return the configured initials service. +func Initials() *initials.Service { + return config.Initials +} + // GetVault returns the configured instance of Vault func GetVault() *Vault { if vault == nil { @@ -727,6 +734,8 @@ func UseViper(v *viper.Viper) error { } } + cachStorage := cache.New(cacheRedis.Client()) + config = &Config{ Host: v.GetString("host"), Port: v.GetInt("port"), @@ -751,6 +760,7 @@ func UseViper(v *viper.Viper) error { CredentialsEncryptorKey: v.GetString("vault.credentials_encryptor_key"), CredentialsDecryptorKey: v.GetString("vault.credentials_decryptor_key"), + Initials: initials.NewService(cachStorage, v.GetString("jobs.imagemagick_convert_cmd")), Fs: Fs{ URL: fsURL, Transport: fsClient.Transport, @@ -799,7 +809,7 @@ func UseViper(v *viper.Viper) error { RateLimitingStorage: rateLimitingRedis, OauthStateStorage: oauthStateRedis, Realtime: realtimeRedis, - CacheStorage: cache.New(cacheRedis.Client()), + CacheStorage: cachStorage, Logger: logger.Options{ Level: v.GetString("log.level"), Syslog: v.GetBool("log.syslog"), diff --git a/pkg/initials/initials.go b/pkg/initials/initials.go index cbb60a05bab..7b2a45c2d0a 100644 --- a/pkg/initials/initials.go +++ b/pkg/initials/initials.go @@ -11,7 +11,7 @@ import ( "unicode" "unicode/utf8" - "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/cache" "github.com/cozy/cozy-stack/pkg/logger" ) @@ -26,9 +26,24 @@ const ( GreyBackground Options = 1 + iota ) -// Image returns an image with the initials for the given name (and the +// Service handle all the interactions with the initials images. +type Service struct { + cache cache.Cache + cmd string +} + +// NewService instantiate a new [Service]. +func NewService(cache cache.Cache, cmd string) *Service { + if cmd == "" { + cmd = "convert" + } + + return &Service{cache, cmd} +} + +// Generate an image with the initials for the given name (and the // content-type to use for the HTTP response). -func Image(publicName string, opts ...Options) ([]byte, string, error) { +func (s *Service) Generate(publicName string, opts ...Options) ([]byte, string, error) { name := strings.TrimSpace(publicName) info := extractInfo(name) for _, opt := range opts { @@ -37,17 +52,16 @@ func Image(publicName string, opts ...Options) ([]byte, string, error) { } } - cache := config.GetConfig().CacheStorage key := "initials:" + info.initials + info.color - if bytes, ok := cache.Get(key); ok { + if bytes, ok := s.cache.Get(key); ok { return bytes, contentType, nil } - bytes, err := draw(info) + bytes, err := s.draw(info) if err != nil { return nil, "", err } - cache.Set(key, bytes, cacheTTL) + s.cache.Set(key, bytes, cacheTTL) return bytes, contentType, nil } @@ -112,7 +126,7 @@ func getColor(name string) string { return colors[sum%len(colors)] } -func draw(info info) ([]byte, error) { +func (s *Service) draw(info info) ([]byte, error) { var env []string { tempDir, err := os.MkdirTemp("", "magick") @@ -123,11 +137,6 @@ func draw(info info) ([]byte, error) { } } - convertCmd := config.GetConfig().Jobs.ImageMagickConvertCmd - if convertCmd == "" { - convertCmd = "convert" - } - // 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{ @@ -153,7 +162,7 @@ func draw(info info) ([]byte, error) { } var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(context.Background(), convertCmd, args...) + cmd := exec.CommandContext(context.Background(), s.cmd, args...) cmd.Env = env cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/web/public/public.go b/web/public/public.go index 00eb58b19c6..75f838a45a8 100644 --- a/web/public/public.go +++ b/web/public/public.go @@ -10,7 +10,7 @@ import ( "github.com/cozy/cozy-stack/model/bitwarden/settings" "github.com/cozy/cozy-stack/pkg/assets" - "github.com/cozy/cozy-stack/pkg/initials" + "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/web/middlewares" "github.com/cozy/cozy-stack/web/statik" "github.com/labstack/echo/v4" @@ -27,7 +27,7 @@ func Avatar(c echo.Context) error { if err != nil { publicName = strings.Split(inst.Domain, ".")[0] } - img, mime, err := initials.Image(publicName) + img, mime, err := config.Initials().Generate(publicName) if err == nil { return c.Blob(http.StatusOK, mime, img) } diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index 107331d5b9b..ed3db1c39ab 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -14,6 +14,7 @@ import ( "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/model/sharing" "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/initials" @@ -691,7 +692,7 @@ func localAvatar(c echo.Context, m sharing.Member) error { m.Status == sharing.MemberStatusPendingInvitation { options = append(options, initials.GreyBackground) } - img, mime, err := initials.Image(name, options...) + img, mime, err := config.Initials().Generate(name, options...) if err != nil { return wrapErrors(err) } From 5ecd41ca3e63ec2ff4a70ea1e004d569a3380741 Mon Sep 17 00:00:00 2001 From: Peltoche Date: Mon, 6 Mar 2023 10:48:25 +0100 Subject: [PATCH 2/5] Rename the `initials` package into `avatar` This package is responsible to handling avatars, the initials are only one possible way to implement avatars and for what I have understood a gravatar implementation could be implemented in the future. --- pkg/{initials/initials.go => avatar/service.go} | 2 +- .../initials_test.go => avatar/service_test.go} | 2 +- pkg/config/config/config.go | 12 ++++++------ web/public/public.go | 2 +- web/sharings/sharings.go | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) rename pkg/{initials/initials.go => avatar/service.go} (99%) rename pkg/{initials/initials_test.go => avatar/service_test.go} (98%) diff --git a/pkg/initials/initials.go b/pkg/avatar/service.go similarity index 99% rename from pkg/initials/initials.go rename to pkg/avatar/service.go index 7b2a45c2d0a..ea1389445ba 100644 --- a/pkg/initials/initials.go +++ b/pkg/avatar/service.go @@ -1,4 +1,4 @@ -package initials +package avatar import ( "bytes" diff --git a/pkg/initials/initials_test.go b/pkg/avatar/service_test.go similarity index 98% rename from pkg/initials/initials_test.go rename to pkg/avatar/service_test.go index b3168c2613b..a71cd95985d 100644 --- a/pkg/initials/initials_test.go +++ b/pkg/avatar/service_test.go @@ -1,4 +1,4 @@ -package initials +package avatar import ( "math/rand" diff --git a/pkg/config/config/config.go b/pkg/config/config/config.go index 78a7f2fff7e..d4bb5d445d3 100644 --- a/pkg/config/config/config.go +++ b/pkg/config/config/config.go @@ -20,9 +20,9 @@ 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/initials" "github.com/cozy/cozy-stack/pkg/keymgmt" "github.com/cozy/cozy-stack/pkg/lock" "github.com/cozy/cozy-stack/pkg/logger" @@ -113,7 +113,7 @@ type Config struct { RemoteAssets map[string]string - Initials *initials.Service + Avatars *avatar.Service Fs Fs CouchDB CouchDB Jobs Jobs @@ -380,9 +380,9 @@ func GetConfig() *Config { return config } -// Initials return the configured initials service. -func Initials() *initials.Service { - return config.Initials +// Avatars return the configured initials service. +func Avatars() *avatar.Service { + return config.Avatars } // GetVault returns the configured instance of Vault @@ -760,7 +760,7 @@ func UseViper(v *viper.Viper) error { CredentialsEncryptorKey: v.GetString("vault.credentials_encryptor_key"), CredentialsDecryptorKey: v.GetString("vault.credentials_decryptor_key"), - Initials: initials.NewService(cachStorage, v.GetString("jobs.imagemagick_convert_cmd")), + Avatars: avatar.NewService(cachStorage, v.GetString("jobs.imagemagick_convert_cmd")), Fs: Fs{ URL: fsURL, Transport: fsClient.Transport, diff --git a/web/public/public.go b/web/public/public.go index 75f838a45a8..ec69659e828 100644 --- a/web/public/public.go +++ b/web/public/public.go @@ -27,7 +27,7 @@ func Avatar(c echo.Context) error { if err != nil { publicName = strings.Split(inst.Domain, ".")[0] } - img, mime, err := config.Initials().Generate(publicName) + img, mime, err := config.Avatars().Generate(publicName) if err == nil { return c.Blob(http.StatusOK, mime, img) } diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index ed3db1c39ab..02fb5c4f16c 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -14,10 +14,10 @@ import ( "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/model/sharing" "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/avatar" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" - "github.com/cozy/cozy-stack/pkg/initials" "github.com/cozy/cozy-stack/pkg/jsonapi" "github.com/cozy/cozy-stack/pkg/logger" "github.com/cozy/cozy-stack/pkg/safehttp" @@ -687,12 +687,12 @@ func localAvatar(c echo.Context, m sharing.Member) error { name = strings.Split(m.Email, "@")[0] } name = strings.ToUpper(name) - var options []initials.Options + var options []avatar.Options if m.Status == sharing.MemberStatusMailNotSent || m.Status == sharing.MemberStatusPendingInvitation { - options = append(options, initials.GreyBackground) + options = append(options, avatar.GreyBackground) } - img, mime, err := config.Initials().Generate(name, options...) + img, mime, err := config.Avatars().Generate(name, options...) if err != nil { return wrapErrors(err) } From d12775624c8c480b0110f0b73cce95012d81046b Mon Sep 17 00:00:00 2001 From: Peltoche Date: Mon, 6 Mar 2023 11:07:38 +0100 Subject: [PATCH 3/5] Move the `convert` logic from `/pkg/avatar` inside an implem This will allow to propose a new implementation based on SVG --- pkg/avatar/initials_convert.go | 88 ++++++++++++++++++++++++++++++++++ pkg/avatar/service.go | 79 +++++++----------------------- pkg/config/config/config.go | 10 ++-- 3 files changed, 113 insertions(+), 64 deletions(-) create mode 100644 pkg/avatar/initials_convert.go diff --git a/pkg/avatar/initials_convert.go b/pkg/avatar/initials_convert.go new file mode 100644 index 00000000000..84a0bc61eb2 --- /dev/null +++ b/pkg/avatar/initials_convert.go @@ -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 +} diff --git a/pkg/avatar/service.go b/pkg/avatar/service.go index ea1389445ba..05b48ab0e0e 100644 --- a/pkg/avatar/service.go +++ b/pkg/avatar/service.go @@ -1,18 +1,14 @@ package avatar import ( - "bytes" "context" "fmt" - "os" - "os/exec" "strings" "time" "unicode" "unicode/utf8" "github.com/cozy/cozy-stack/pkg/cache" - "github.com/cozy/cozy-stack/pkg/logger" ) // Options can be used to give options for the generated image @@ -26,19 +22,31 @@ const ( 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 - cmd string + cache cache.Cache + initials Initials } // NewService instantiate a new [Service]. -func NewService(cache cache.Cache, cmd string) *Service { +func NewService(cache cache.Cache, cmd string) (*Service, error) { if cmd == "" { cmd = "convert" } - return &Service{cache, cmd} + 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 } // Generate an image with the initials for the given name (and the @@ -57,12 +65,12 @@ func (s *Service) Generate(publicName string, opts ...Options) ([]byte, string, return bytes, contentType, nil } - bytes, err := s.draw(info) + 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, contentType, nil + return bytes, s.initials.ContentType(), nil } // See https://github.com/cozy/cozy-ui/blob/master/react/Avatar/index.jsx#L9-L26 @@ -125,54 +133,3 @@ func getColor(name string) string { } return colors[sum%len(colors)] } - -func (s *Service) draw(info info) ([]byte, error) { - var env []string - { - tempDir, err := os.MkdirTemp("", "magick") - if err == nil { - defer os.RemoveAll(tempDir) - envTempDir := fmt.Sprintf("MAGICK_TEMPORARY_PATH=%s", tempDir) - env = []string{envTempDir} - } - } - - // 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", info.color, - "-draw", "circle 64,64 0,64", - // Add the initials - "-fill", "white", - "-font", "Lato-Regular", - "-pointsize", "64", - "-gravity", "center", - "-annotate", "+0,+0", - info.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(context.Background(), s.cmd, args...) - cmd.Env = env - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - logger.WithNamespace("initials"). - WithField("stderr", stderr.String()). - WithField("initials", info.initials). - WithField("color", info.color). - Errorf("imagemagick failed: %s", err) - return nil, err - } - return stdout.Bytes(), nil -} diff --git a/pkg/config/config/config.go b/pkg/config/config/config.go index d4bb5d445d3..3d4d06aca73 100644 --- a/pkg/config/config/config.go +++ b/pkg/config/config/config.go @@ -734,7 +734,11 @@ func UseViper(v *viper.Viper) error { } } - cachStorage := cache.New(cacheRedis.Client()) + 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"), @@ -760,7 +764,7 @@ func UseViper(v *viper.Viper) error { CredentialsEncryptorKey: v.GetString("vault.credentials_encryptor_key"), CredentialsDecryptorKey: v.GetString("vault.credentials_decryptor_key"), - Avatars: avatar.NewService(cachStorage, v.GetString("jobs.imagemagick_convert_cmd")), + Avatars: avatars, Fs: Fs{ URL: fsURL, Transport: fsClient.Transport, @@ -809,7 +813,7 @@ func UseViper(v *viper.Viper) error { RateLimitingStorage: rateLimitingRedis, OauthStateStorage: oauthStateRedis, Realtime: realtimeRedis, - CacheStorage: cachStorage, + CacheStorage: cacheStorage, Logger: logger.Options{ Level: v.GetString("log.level"), Syslog: v.GetBool("log.syslog"), From 59878682d9ddb71309394ac72ac1859ff5ce870d Mon Sep 17 00:00:00 2001 From: Peltoche Date: Mon, 6 Mar 2023 11:44:49 +0100 Subject: [PATCH 4/5] Rename `pkg/avatar.Service.Generate` to `GenerateInitials` --- pkg/avatar/service.go | 4 ++-- web/public/public.go | 2 +- web/sharings/sharings.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/avatar/service.go b/pkg/avatar/service.go index 05b48ab0e0e..93aa76afae4 100644 --- a/pkg/avatar/service.go +++ b/pkg/avatar/service.go @@ -49,9 +49,9 @@ func NewService(cache cache.Cache, cmd string) (*Service, error) { return &Service{cache, initials}, nil } -// Generate an image with the initials for the given name (and the +// GenerateInitials an image with the initials for the given name (and the // content-type to use for the HTTP response). -func (s *Service) Generate(publicName string, opts ...Options) ([]byte, string, error) { +func (s *Service) GenerateInitials(publicName string, opts ...Options) ([]byte, string, error) { name := strings.TrimSpace(publicName) info := extractInfo(name) for _, opt := range opts { diff --git a/web/public/public.go b/web/public/public.go index ec69659e828..fba32795c69 100644 --- a/web/public/public.go +++ b/web/public/public.go @@ -27,7 +27,7 @@ func Avatar(c echo.Context) error { if err != nil { publicName = strings.Split(inst.Domain, ".")[0] } - img, mime, err := config.Avatars().Generate(publicName) + img, mime, err := config.Avatars().GenerateInitials(publicName) if err == nil { return c.Blob(http.StatusOK, mime, img) } diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index 02fb5c4f16c..e23df17dff4 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -692,7 +692,7 @@ func localAvatar(c echo.Context, m sharing.Member) error { m.Status == sharing.MemberStatusPendingInvitation { options = append(options, avatar.GreyBackground) } - img, mime, err := config.Avatars().Generate(name, options...) + img, mime, err := config.Avatars().GenerateInitials(name, options...) if err != nil { return wrapErrors(err) } From f137ef24b1c70955b3346aa3087aaf9e0c48c322 Mon Sep 17 00:00:00 2001 From: Peltoche Date: Tue, 7 Mar 2023 09:22:42 +0100 Subject: [PATCH 5/5] Add a test for the PNGInitials implementation --- pkg/avatar/initials_convert_test.go | 46 +++++++++++++++++++++++ pkg/avatar/testdata/initials-convert.png | Bin 0 -> 5981 bytes 2 files changed, 46 insertions(+) create mode 100644 pkg/avatar/initials_convert_test.go create mode 100755 pkg/avatar/testdata/initials-convert.png diff --git a/pkg/avatar/initials_convert_test.go b/pkg/avatar/initials_convert_test.go new file mode 100644 index 00000000000..73060ab1bd1 --- /dev/null +++ b/pkg/avatar/initials_convert_test.go @@ -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)) + } + } +} diff --git a/pkg/avatar/testdata/initials-convert.png b/pkg/avatar/testdata/initials-convert.png new file mode 100755 index 0000000000000000000000000000000000000000..724148c60571596299dc0696fce9dbc78b9cf764 GIT binary patch literal 5981 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 C8##sm literal 0 HcmV?d00001