diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 743d2308..8bbe8bd9 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" + profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" ) // GetFileCleanupJobs returns the CronJob actors @@ -75,7 +76,7 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) // Create a map to track which initials are in use initialsInUse := make(map[string]struct{}) for _, user := range users { - initialsInUse[user.Initials()] = struct{}{} + initialsInUse[profilepicture.Initials(user.FirstName, user.LastName, user.Username)] = struct{}{} } defaultPicturesDir := path.Join("profile-pictures", "defaults") diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 3db317b1..ee05ceab 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -8,7 +8,6 @@ import ( "github.com/go-webauthn/webauthn/webauthn" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" - "github.com/pocket-id/pocket-id/backend/internal/utils" ) type User struct { @@ -88,16 +87,6 @@ func (u User) FullName() string { return u.Username } -func (u User) Initials() string { - first := utils.GetFirstCharacter(u.FirstName) - last := utils.GetFirstCharacter(u.LastName) - if first != "" || last != "" { - return strings.ToUpper(first + last) - } - - return strings.ToUpper(utils.GetFirstCharacter(u.Username)) -} - func (u User) LastModified() time.Time { if u.UpdatedAt != nil { return u.UpdatedAt.ToTime() diff --git a/backend/internal/model/user_test.go b/backend/internal/model/user_test.go deleted file mode 100644 index 1bb24e2d..00000000 --- a/backend/internal/model/user_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package model - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestUserInitials(t *testing.T) { - tests := []struct { - name string - user User - expected string - }{ - { - name: "first and last name", - user: User{Username: "username", FirstName: "John", LastName: "Doe"}, - expected: "JD", - }, - { - name: "first name only", - user: User{Username: "username", FirstName: "John"}, - expected: "J", - }, - { - name: "last name only", - user: User{Username: "username", LastName: "Doe"}, - expected: "D", - }, - { - name: "ASCII username", - user: User{Username: "username"}, - expected: "U", - }, - { - name: "single-character username", - user: User{Username: "a"}, - expected: "A", - }, - { - name: "empty username", - user: User{}, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.user.Initials()) - }) - } -} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 1daddea1..2b0c1f97 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -122,7 +122,8 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io. } // Try cached default for initials - defaultPicturePath := path.Join("profile-pictures", "defaults", user.Initials()+".png") + initials := profilepicture.Initials(user.FirstName, user.LastName, user.Username) + defaultPicturePath := path.Join("profile-pictures", "defaults", initials+".png") file, size, err = s.fileStorage.Open(ctx, defaultPicturePath) if err == nil { return file, size, nil @@ -131,7 +132,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io. } // Create and return generated default with initials - defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials()) + defaultPicture, err := profilepicture.CreateDefaultProfilePicture(initials) if err != nil { return nil, 0, err } @@ -144,7 +145,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io. // Use bytes.NewReader because we need an io.ReadSeeker rErr := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes)) if rErr != nil { - slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", rErr)) + slog.Error("Failed to cache default profile picture", slog.String("initials", initials), slog.Any("error", rErr)) } }() diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go index 316b7f95..7814c04d 100644 --- a/backend/internal/utils/image/profile_picture.go +++ b/backend/internal/utils/image/profile_picture.go @@ -7,6 +7,8 @@ import ( "image" "image/color" "io" + "strings" + "sync" "github.com/boxes-ltd/imaging" "github.com/disintegration/imageorient" @@ -15,6 +17,7 @@ import ( "golang.org/x/image/math/fixed" "golang.org/x/image/webp" + "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/resources" ) @@ -84,21 +87,58 @@ func centerSquare(img image.Image) image.Image { return sub.SubImage(image.Rect(x, y, x+side, y+side)) } +var loadInitialsFont = sync.OnceValues(func() (*opentype.Font, error) { + fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf") + if err != nil { + return nil, fmt.Errorf("failed to read font file: %w", err) + } + + fontFace, err := opentype.Parse(fontBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse font: %w", err) + } + + return fontFace, nil +}) + +// Initials returns the initials shown on the default profile picture +func Initials(firstName, lastName, username string) string { + // The font only covers Latin, so names it can't render fall back to the username, which is always ASCII + initials := strings.ToUpper(utils.GetFirstCharacter(firstName) + utils.GetFirstCharacter(lastName)) + if initials != "" && canRenderInitials(initials) { + return initials + } + + return strings.ToUpper(utils.GetFirstCharacter(username)) +} + +// canRenderInitials reports whether the initials font has a glyph for every character of the initials +// The font only covers Latin, so other scripts would otherwise be drawn as empty boxes +func canRenderInitials(initials string) bool { + fontFace, err := loadInitialsFont() + if err != nil { + return false + } + + for _, r := range initials { + idx, err := fontFace.GlyphIndex(nil, r) + if err != nil || idx == 0 { + return false + } + } + + return true +} + // CreateDefaultProfilePicture creates a profile picture with the initials func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) { // Create a blank image with a white background img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255}) // Load the font - fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf") + fontFace, err := loadInitialsFont() if err != nil { - return nil, fmt.Errorf("failed to read font file: %w", err) - } - - // Parse the font - fontFace, err := opentype.Parse(fontBytes) - if err != nil { - return nil, fmt.Errorf("failed to parse font: %w", err) + return nil, err } // Create a font.Face with a specific size diff --git a/backend/internal/utils/image/profile_picture_test.go b/backend/internal/utils/image/profile_picture_test.go new file mode 100644 index 00000000..8bfe4965 --- /dev/null +++ b/backend/internal/utils/image/profile_picture_test.go @@ -0,0 +1,41 @@ +package profilepicture + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInitials(t *testing.T) { + tests := []struct { + name string + firstName string + lastName string + username string + expected string + }{ + {name: "first and last name", firstName: "John", lastName: "Doe", username: "username", expected: "JD"}, + {name: "first name only", firstName: "John", username: "username", expected: "J"}, + {name: "last name only", lastName: "Doe", username: "username", expected: "D"}, + {name: "accented Latin name", firstName: "émile", lastName: "Öz", username: "username", expected: "ÉÖ"}, + {name: "name the font can't render falls back to username", firstName: "민준", lastName: "김", username: "kim", expected: "K"}, + {name: "partially unrenderable name falls back to username", firstName: "John", lastName: "Иванов", username: "ivan", expected: "I"}, + {name: "ASCII username", username: "username", expected: "U"}, + {name: "single-character username", username: "a", expected: "A"}, + {name: "empty username", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, Initials(tt.firstName, tt.lastName, tt.username)) + }) + } +} + +func TestCanRenderInitials(t *testing.T) { + assert.True(t, canRenderInitials("ES")) + assert.True(t, canRenderInitials("ÉÖ")) + assert.True(t, canRenderInitials("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")) + assert.False(t, canRenderInitials("한글")) + assert.False(t, canRenderInitials("EЖ")) +}