mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
136 lines
4.6 KiB
Go
136 lines
4.6 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package avatar
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
|
|
"github.com/hanzoai/git/modules/avatar/identicon"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
|
|
_ "golang.org/x/image/webp" // for processing webp images
|
|
_ "image/gif" // for processing gif images
|
|
_ "image/jpeg" // for processing jpeg images
|
|
|
|
"golang.org/x/image/draw"
|
|
)
|
|
|
|
// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is
|
|
// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the
|
|
// usual size of avatar image saved on server, unless the original file is smaller
|
|
// than the size after resizing.
|
|
const DefaultAvatarSize = 256
|
|
|
|
// RandomImageWithSize generates and returns a random avatar image unique to input data
|
|
// in custom size (height and width).
|
|
func RandomImageWithSize(size int, data []byte) image.Image {
|
|
// we use white as background, and use dark colors to draw blocks
|
|
imgMaker := identicon.New(size, color.White, identicon.DarkColors)
|
|
return imgMaker.Make(data)
|
|
}
|
|
|
|
// RandomImageDefaultSize generates and returns a random avatar image unique to input data
|
|
// in default size (height and width).
|
|
func RandomImageDefaultSize(data []byte) image.Image {
|
|
return RandomImageWithSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data)
|
|
}
|
|
|
|
// processAvatarImage process the avatar image data, crop and resize it if necessary.
|
|
// the returned data could be the original image if no processing is needed.
|
|
func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) {
|
|
imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("image.DecodeConfig: %w", err)
|
|
}
|
|
|
|
// for safety, only accept known types explicitly
|
|
if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" {
|
|
return nil, errors.New("unsupported avatar image type")
|
|
}
|
|
|
|
// do not process image which is too large, it would consume too much memory
|
|
if imgCfg.Width > setting.Avatar.MaxWidth {
|
|
return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth)
|
|
}
|
|
if imgCfg.Height > setting.Avatar.MaxHeight {
|
|
return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight)
|
|
}
|
|
|
|
// If the origin is small enough, just use it, then APNG could be supported,
|
|
// otherwise, if the image is processed later, APNG loses animation.
|
|
// And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails.
|
|
// So for animated webp, if the uploaded file is smaller than maxOriginSize, it will be used, if it's larger, there will be an error.
|
|
if len(data) < int(maxOriginSize) {
|
|
return data, nil
|
|
}
|
|
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("image.Decode: %w", err)
|
|
}
|
|
|
|
// try to crop and resize the origin image if necessary
|
|
img = cropSquare(img)
|
|
|
|
targetSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor
|
|
img = scale(img, targetSize, targetSize, draw.BiLinear)
|
|
|
|
// try to encode the cropped/resized image to png
|
|
bs := bytes.Buffer{}
|
|
if err = png.Encode(&bs, img); err != nil {
|
|
return nil, err
|
|
}
|
|
resized := bs.Bytes()
|
|
|
|
// usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller
|
|
if len(data) <= len(resized) {
|
|
return data, nil
|
|
}
|
|
|
|
return resized, nil
|
|
}
|
|
|
|
// ProcessAvatarImage process the avatar image data, crop and resize it if necessary.
|
|
// the returned data could be the original image if no processing is needed.
|
|
func ProcessAvatarImage(data []byte) ([]byte, error) {
|
|
return processAvatarImage(data, setting.Avatar.MaxOriginSize)
|
|
}
|
|
|
|
// scale resizes the image to width x height using the given scaler.
|
|
func scale(src image.Image, width, height int, scale draw.Scaler) image.Image {
|
|
rect := image.Rect(0, 0, width, height)
|
|
dst := image.NewRGBA(rect)
|
|
scale.Scale(dst, rect, src, src.Bounds(), draw.Over, nil)
|
|
return dst
|
|
}
|
|
|
|
// cropSquare crops the largest square image from the center of the image.
|
|
// If the image is already square, it is returned unchanged.
|
|
func cropSquare(src image.Image) image.Image {
|
|
bounds := src.Bounds()
|
|
if bounds.Dx() == bounds.Dy() {
|
|
return src
|
|
}
|
|
|
|
var rect image.Rectangle
|
|
if bounds.Dx() > bounds.Dy() {
|
|
// width > height
|
|
size := bounds.Dy()
|
|
rect = image.Rect((bounds.Dx()-size)/2, 0, (bounds.Dx()+size)/2, size)
|
|
} else {
|
|
// width < height
|
|
size := bounds.Dx()
|
|
rect = image.Rect(0, (bounds.Dy()-size)/2, size, (bounds.Dy()+size)/2)
|
|
}
|
|
|
|
dst := image.NewRGBA(rect)
|
|
draw.Draw(dst, rect, src, rect.Min, draw.Src)
|
|
return dst
|
|
}
|