package rankcard import ( "bytes" "context" "fmt" "image" "image/png" "io" "math" "net/http" "time" "github.com/fogleman/gg" "golang.org/x/image/draw" "golang.org/x/image/font" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/font/opentype" ) type Data struct { Name string Level int XP int64 Percent float64 // 0..1 progress towards next level Avatar string // URL } func RenderPNG(ctx context.Context, d Data) ([]byte, error) { const ( w = 900 h = 300 px = 30 avatarSize = 150 barX = 30 barY = 220 barW = 650 barH = 40 ) percent := clamp01(d.Percent) dc := gg.NewContext(w, h) dc.SetHexColor("#141414") dc.Clear() // Right white polygon dc.SetHexColor("#FFFFFF") dc.NewSubPath() dc.MoveTo(600, 0) dc.LineTo(750, 300) dc.LineTo(900, 300) dc.LineTo(900, 0) dc.ClosePath() dc.Fill() // Avatar (circle clip) if img, err := fetchImage(ctx, d.Avatar); err == nil && img != nil { img = resizeImage(img, avatarSize, avatarSize) dc.Push() dc.DrawCircle(px+avatarSize/2, px+avatarSize/2, avatarSize/2) dc.Clip() dc.DrawImage(img, px, px) dc.Pop() // Defensive: ensure no clip leaks past avatar drawing. dc.ResetClip() } // Progress bar background dc.SetHexColor("#FFFFFF") dc.DrawRoundedRectangle(barX, barY, barW, barH, 20) dc.Fill() // Progress bar fill fillW := barW * percent if fillW > 0 { dc.SetHexColor("#282828") // Keep radius from looking weird on small widths r := math.Min(20, fillW/2) dc.DrawRoundedRectangle(barX, barY, fillW, barH, r) dc.Fill() } // Text titleFace, smallFace, err := loadFontFaces() if err != nil { return nil, err } dc.SetHexColor("#FFFFFF") dc.SetFontFace(titleFace) dc.DrawStringAnchored(d.Name, 200, 60, 0, 0.5) dc.SetLineWidth(2) dc.DrawLine(200, 100, 550, 100) dc.Stroke() dc.SetFontFace(smallFace) dc.DrawStringAnchored( fmt.Sprintf("Level - %d | XP - %d | %d%%", d.Level, d.XP, int64(math.Round(percent*100))), 200, 145, 0, 0.5, ) // Watermark to verify latest render is running dc.SetFontFace(smallFace) dc.SetHexColor("#FFCC00") dc.DrawStringAnchored("velox-go rankcard", 890, 290, 1, 1) var buf bytes.Buffer if err := png.Encode(&buf, dc.Image()); err != nil { return nil, err } return buf.Bytes(), nil } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func loadFontFaces() (title font.Face, small font.Face, err error) { f, err := opentype.Parse(goregular.TTF) if err != nil { return nil, nil, err } title, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 40, DPI: 72, Hinting: font.HintingFull}) if err != nil { return nil, nil, err } small, err = opentype.NewFace(f, &opentype.FaceOptions{Size: 30, DPI: 72, Hinting: font.HintingFull}) if err != nil { return nil, nil, err } return title, small, nil } func fetchImage(ctx context.Context, url string) (image.Image, error) { if url == "" { return nil, fmt.Errorf("empty avatar url") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode) } img, _, err := image.Decode(resp.Body) return img, err } func resizeImage(src image.Image, w, h int) image.Image { dst := image.NewRGBA(image.Rect(0, 0, w, h)) draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) return dst }