Files
vikunja/pkg/modules/avatar/initials/initials.go

249 lines
7.1 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package initials
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"strconv"
"strings"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/user"
"github.com/disintegration/imaging"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
)
// Provider represents the provider implementation of the initials provider
type Provider struct {
}
// FlushCache removes cached initials avatars for a user
func (p *Provider) FlushCache(u *user.User) error {
if err := keyvalue.Del(getCacheKey("full", u.ID)); err != nil {
return err
}
return keyvalue.DelPrefix(getCacheKey("resized", u.ID))
}
var (
avatarBgColors = []*color.RGBA{
{R: 69, G: 189, B: 243, A: 255},
{R: 224, G: 143, B: 112, A: 255},
{R: 77, G: 182, B: 172, A: 255},
{R: 149, G: 117, B: 205, A: 255},
{R: 176, G: 133, B: 94, A: 255},
{R: 240, G: 98, B: 146, A: 255},
{R: 163, G: 211, B: 108, A: 255},
{R: 121, G: 134, B: 203, A: 255},
{R: 241, G: 185, B: 29, A: 255},
}
)
const (
dpi = 72
defaultSize = 1024
)
func drawImage(text rune, bg *color.RGBA) (img *image.RGBA64, err error) {
size := defaultSize
fontSize := float64(size) * 0.8
// Inspired by https://github.com/holys/initials-avatar
// Get the font
f, err := truetype.Parse(goregular.TTF)
if err != nil {
return img, err
}
// Build the image background
img = image.NewRGBA64(image.Rect(0, 0, size, size))
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
// Add the text
drawer := &font.Drawer{
Dst: img,
Src: image.White,
Face: truetype.NewFace(f, &truetype.Options{
Size: fontSize,
DPI: dpi,
Hinting: font.HintingNone,
}),
}
// Font Index
fi := f.Index(text)
// Glyph example: http://www.freetype.org/freetype2/docs/tutorial/metrics.png
var gbuf truetype.GlyphBuf
fsize := fixed.Int26_6(fontSize * dpi * (64.0 / 72.0))
err = gbuf.Load(f, fsize, fi, font.HintingFull)
if err != nil {
drawer.DrawString("")
return img, err
}
// Center
dY := (size - int(gbuf.Bounds.Max.Y-gbuf.Bounds.Min.Y)>>6) / 2
dX := (size - int(gbuf.Bounds.Max.X-gbuf.Bounds.Min.X)>>6) / 2
y := int(gbuf.Bounds.Max.Y>>6) + dY
x := 0 - int(gbuf.Bounds.Min.X>>6) + dX
drawer.Dot = fixed.Point26_6{
X: fixed.I(x),
Y: fixed.I(y),
}
drawer.DrawString(string(text))
return img, err
}
func getCacheKey(prefix string, keys ...int64) string {
result := "avatar_initials_" + prefix
for i, key := range keys {
result += strconv.Itoa(int(key))
if i < len(keys) {
result += "_"
}
}
return result
}
func getAvatarForUser(u *user.User) (fullSizeAvatar *image.RGBA64, err error) {
return getAvatarForUserWithDepth(u, 0)
}
func getAvatarForUserWithDepth(u *user.User, recursionDepth int) (fullSizeAvatar *image.RGBA64, err error) {
// Prevent infinite recursion - max 3 attempts
if recursionDepth >= 3 {
return nil, fmt.Errorf("maximum recursion depth reached while generating avatar for user %d", u.ID)
}
cacheKey := getCacheKey("full", u.ID)
result, err := keyvalue.Remember(cacheKey, func() (any, error) {
log.Debugf("Initials avatar for user %d not cached, creating...", u.ID)
avatarText := u.Name
if avatarText == "" {
avatarText = u.Username
}
firstRune := []rune(strings.ToUpper(avatarText))[0]
bg := avatarBgColors[int(u.ID)%len(avatarBgColors)] // Random color based on the user id
res, err := drawImage(firstRune, bg)
if err != nil {
return nil, err
}
return *res, nil
})
if err != nil {
return nil, err
}
// Safe type assertion to handle cases where cached data might be corrupted or in legacy format
aa, ok := result.(image.RGBA64)
if !ok {
// Log the type mismatch with the actual stored value for debugging
log.Errorf("Invalid cached image type for user %d. Expected image.RGBA64, got %T with value: %+v. Clearing cache and regenerating.", u.ID, result, result)
// Clear the invalid cache entry
if err := keyvalue.Del(cacheKey); err != nil {
log.Errorf("Failed to clear invalid cache entry for key %s: %v", cacheKey, err)
}
// Regenerate the avatar by calling the function again (without the corrupted cache)
return getAvatarForUserWithDepth(u, recursionDepth+1)
}
return &aa, nil
}
// CachedAvatar represents a cached avatar with its content and mime type
type CachedAvatar struct {
Content []byte
MimeType string
}
// GetAvatar returns an initials avatar for a user
func (p *Provider) GetAvatar(u *user.User, size int64) (avatar []byte, mimeType string, err error) {
return p.getAvatarWithDepth(u, size, 0)
}
func (p *Provider) getAvatarWithDepth(u *user.User, size int64, recursionDepth int) (avatar []byte, mimeType string, err error) {
// Prevent infinite recursion - max 3 attempts
if recursionDepth >= 3 {
return nil, "", fmt.Errorf("maximum recursion depth reached while generating avatar for user %d, size %d", u.ID, size)
}
cacheKey := getCacheKey("resized", u.ID, size)
result, err := keyvalue.Remember(cacheKey, func() (any, error) {
log.Debugf("Initials avatar for user %d and size %d not cached, creating...", u.ID, size)
fullAvatar, err := getAvatarForUser(u)
if err != nil {
return nil, err
}
img := imaging.Resize(fullAvatar, int(size), int(size), imaging.Lanczos)
buf := &bytes.Buffer{}
err = png.Encode(buf, img)
if err != nil {
return nil, err
}
avatar := buf.Bytes()
mimeType := "image/png"
cachedAvatar := CachedAvatar{
Content: avatar,
MimeType: mimeType,
}
return cachedAvatar, nil
})
if err != nil {
return nil, "", err
}
// Safe type assertion to handle cases where cached data might be corrupted or in legacy format
cachedAvatar, ok := result.(CachedAvatar)
if !ok {
// Log the type mismatch with the actual stored value for debugging
log.Errorf("Invalid cached avatar type for user %d, size %d. Expected CachedAvatar, got %T with value: %+v. Clearing cache and regenerating.", u.ID, size, result, result)
// Clear the invalid cache entry
if err := keyvalue.Del(cacheKey); err != nil {
log.Errorf("Failed to clear invalid cache entry for key %s: %v", cacheKey, err)
}
// Regenerate the avatar by calling the function again (without the corrupted cache)
return p.getAvatarWithDepth(u, size, recursionDepth+1)
}
return cachedAvatar.Content, cachedAvatar.MimeType, nil
}