Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
4.3 KiB
Go
169 lines
4.3 KiB
Go
package entity
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"hash/adler32"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"math"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
avatarSize = 256
|
|
avatarSat = 0.3
|
|
avatarVal = 0.9
|
|
glyphW = 5
|
|
glyphH = 7
|
|
glyphPx = 20
|
|
)
|
|
|
|
// LetterAvatarPNG draws a first-letter avatar. Hue follows FastAdmin letter_avatar (adler32 % 360).
|
|
func LetterAvatarPNG(text string) ([]byte, error) {
|
|
img := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
|
|
bg := AvatarColor(text)
|
|
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
|
|
if letter := AvatarLetter(text); letter != "" {
|
|
if rows, ok := avatarGlyphs[letter[0]]; ok {
|
|
blitGlyph(img, rows, color.White)
|
|
} else {
|
|
fillPerson(img, color.White)
|
|
}
|
|
} else {
|
|
fillPerson(img, color.White)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
const avatarDataURIPrefix = "data:image/svg+xml;base64,"
|
|
|
|
// LetterAvatarDataURI is a compact SVG fallback (FastAdmin letter_avatar). Always fits users.avatar VARCHAR(512).
|
|
func LetterAvatarDataURI(text string) string {
|
|
c := AvatarColor(text)
|
|
fill := fmt.Sprintf("rgb(%d,%d,%d)", c.R, c.G, c.B)
|
|
var svg string
|
|
if letter := AvatarLetter(text); letter != "" {
|
|
svg = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="%s"/><text x="50" y="50" font-size="50" fill="#fff" text-anchor="middle" dominant-baseline="central">%s</text></svg>`, fill, letter)
|
|
} else {
|
|
svg = fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="0 0 72 72"><rect width="72" height="72" rx="16" fill="%s"/><circle cx="36" cy="28" r="12" fill="#fff"/><path d="M14 58c4-12 14-18 22-18s18 6 22 18" fill="#fff"/></svg>`, fill)
|
|
}
|
|
return avatarDataURIPrefix + base64.StdEncoding.EncodeToString([]byte(svg))
|
|
}
|
|
|
|
// AvatarLetter is the uppercase first rune. Non-Latin glyphs use the silhouette fallback.
|
|
func AvatarLetter(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return "?"
|
|
}
|
|
r, _ := utf8.DecodeRuneInString(text)
|
|
if r == utf8.RuneError {
|
|
return "?"
|
|
}
|
|
r = unicode.ToUpper(r)
|
|
if r > unicode.MaxASCII || !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
|
return ""
|
|
}
|
|
return string(r)
|
|
}
|
|
|
|
// AvatarColor is the pastel background from adler32(text) % 360, sat 0.3 / val 0.9.
|
|
func AvatarColor(text string) color.RGBA {
|
|
hue := float64(adler32.Checksum([]byte(text))%360) / 360
|
|
return hsvRGB(hue, avatarSat, avatarVal)
|
|
}
|
|
|
|
func blitGlyph(img *image.RGBA, rows [glyphH]byte, c color.Color) {
|
|
gw, gh := glyphW*glyphPx, glyphH*glyphPx
|
|
ox := (avatarSize - gw) / 2
|
|
oy := (avatarSize - gh) / 2
|
|
for y := 0; y < glyphH; y++ {
|
|
row := rows[y]
|
|
for x := 0; x < glyphW; x++ {
|
|
if row&(1<<uint(glyphW-1-x)) == 0 {
|
|
continue
|
|
}
|
|
r := image.Rect(ox+x*glyphPx, oy+y*glyphPx, ox+(x+1)*glyphPx, oy+(y+1)*glyphPx)
|
|
draw.Draw(img, r, &image.Uniform{C: c}, image.Point{}, draw.Src)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fillPerson(img *image.RGBA, c color.Color) {
|
|
fillCircle(img, avatarSize/2, 100, 43, c)
|
|
fillShoulders(img, c)
|
|
}
|
|
|
|
func fillCircle(img *image.RGBA, cx, cy, r int, c color.Color) {
|
|
rr := r * r
|
|
b := img.Bounds()
|
|
for y := cy - r; y <= cy+r; y++ {
|
|
for x := cx - r; x <= cx+r; x++ {
|
|
if !image.Pt(x, y).In(b) {
|
|
continue
|
|
}
|
|
dx, dy := x-cx, y-cy
|
|
if dx*dx+dy*dy <= rr {
|
|
img.Set(x, y, c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func fillShoulders(img *image.RGBA, c color.Color) {
|
|
const cx, cy, rx, ry = 128, 206, 78, 64
|
|
b := img.Bounds()
|
|
rx2, ry2 := float64(rx*rx), float64(ry*ry)
|
|
for y := cy - ry; y <= avatarSize-1; y++ {
|
|
for x := cx - rx; x <= cx+rx; x++ {
|
|
if !image.Pt(x, y).In(b) {
|
|
continue
|
|
}
|
|
dx := float64(x - cx)
|
|
dy := float64(y - cy)
|
|
if dx*dx/rx2+dy*dy/ry2 <= 1 {
|
|
img.Set(x, y, c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func hsvRGB(h, s, v float64) color.RGBA {
|
|
i := math.Floor(h * 6)
|
|
f := h*6 - i
|
|
p := v * (1 - s)
|
|
q := v * (1 - f*s)
|
|
t := v * (1 - (1-f)*s)
|
|
var r, g, b float64
|
|
switch int(i) % 6 {
|
|
case 0:
|
|
r, g, b = v, t, p
|
|
case 1:
|
|
r, g, b = q, v, p
|
|
case 2:
|
|
r, g, b = p, v, t
|
|
case 3:
|
|
r, g, b = p, q, v
|
|
case 4:
|
|
r, g, b = t, p, v
|
|
default:
|
|
r, g, b = v, p, q
|
|
}
|
|
return color.RGBA{
|
|
R: uint8(math.Floor(r * 255)),
|
|
G: uint8(math.Floor(g * 255)),
|
|
B: uint8(math.Floor(b * 255)),
|
|
A: 255,
|
|
}
|
|
}
|