Files
at-container-registry/pkg/appview/ogcard/card.go
T

417 lines
11 KiB
Go

// Package ogcard provides OpenGraph card image generation for ATCR.
package ogcard
import (
"image"
"image/color"
"image/draw"
_ "image/gif" // Register GIF decoder for image.Decode
_ "image/jpeg" // Register JPEG decoder for image.Decode
"image/png"
"io"
"log/slog"
"net/http"
"time"
"github.com/goki/freetype"
"github.com/goki/freetype/truetype"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
_ "golang.org/x/image/webp" // Register WEBP decoder for image.Decode
)
// Text alignment constants
const (
AlignLeft = iota
AlignCenter
AlignRight
)
// Layout constants for OG cards
const (
// Card dimensions
CardWidth = 1200
CardHeight = 630
// Padding and sizing
Padding = 60
AvatarSize = 180
// Positioning offsets
IconTopOffset = 50 // Y offset from padding for icon
TextGapAfterIcon = 40 // X gap between icon and text
TextTopOffset = 50 // Y offset from icon top for text baseline
// Font sizes
FontTitle = 48.0
FontDescription = 32.0
FontStats = 40.0 // Larger for visibility when scaled down
FontBadge = 32.0 // Larger for visibility when scaled down
FontBranding = 28.0
// Spacing
LineSpacingLarge = 65 // Gap after title
LineSpacingSmall = 60 // Gap between description lines
StatsIconGap = 48 // Gap between stat icon and text
StatsItemGap = 60 // Gap between stat items
BadgeGap = 20 // Gap between badges
)
// Layout holds computed positions for a standard OG card layout
type Layout struct {
IconX int
IconY int
TextX float64
TextY float64
StatsY int
MaxWidth int // For text wrapping
}
// StandardLayout returns the standard OG card layout with computed positions
func StandardLayout() Layout {
iconX := Padding
iconY := Padding + IconTopOffset
textX := float64(iconX + AvatarSize + TextGapAfterIcon)
textY := float64(iconY + TextTopOffset)
statsY := CardHeight - Padding - 10
maxWidth := CardWidth - int(textX) - Padding
return Layout{
IconX: iconX,
IconY: iconY,
TextX: textX,
TextY: textY,
StatsY: statsY,
MaxWidth: maxWidth,
}
}
// Card represents an OG image canvas
type Card struct {
img *image.RGBA
width int
height int
}
// NewCard creates a new OG card with the standard 1200x630 dimensions
func NewCard() *Card {
return NewCardWithSize(1200, 630)
}
// NewCardWithSize creates a new OG card with custom dimensions
func NewCardWithSize(width, height int) *Card {
img := image.NewRGBA(image.Rect(0, 0, width, height))
return &Card{
img: img,
width: width,
height: height,
}
}
// Fill fills the entire card with a solid color
func (c *Card) Fill(col color.Color) {
draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
}
// DrawRect draws a filled rectangle
func (c *Card) DrawRect(x, y, w, h int, col color.Color) {
rect := image.Rect(x, y, x+w, y+h)
draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Over)
}
// DrawText draws text at the specified position.
func (c *Card) DrawText(text string, x, y float64, size float64, col color.Color, align int, bold bool) {
f := regularFont
if bold {
f = boldFont
}
if f == nil {
return // No font loaded
}
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetFont(f)
ctx.SetFontSize(size)
ctx.SetClip(c.img.Bounds())
ctx.SetDst(c.img)
ctx.SetSrc(image.NewUniform(col))
// Calculate text width for alignment
if align != AlignLeft {
opts := truetype.Options{Size: size, DPI: 72}
face := truetype.NewFace(f, &opts)
defer face.Close()
textWidth := font.MeasureString(face, text).Round()
switch align {
case AlignCenter:
x -= float64(textWidth) / 2
case AlignRight:
x -= float64(textWidth)
}
}
pt := freetype.Pt(int(x), int(y))
if _, err := ctx.DrawString(text, pt); err != nil {
slog.Warn("Failed to draw text", "text", text, "error", err)
}
}
// MeasureText returns the width of text in pixels
func (c *Card) MeasureText(text string, size float64, bold bool) int {
f := regularFont
if bold {
f = boldFont
}
if f == nil {
return 0
}
opts := truetype.Options{Size: size, DPI: 72}
face := truetype.NewFace(f, &opts)
defer face.Close()
return font.MeasureString(face, text).Round()
}
// DrawTextWrapped draws text with word wrapping within maxWidth
// Returns the Y position after the last line
func (c *Card) DrawTextWrapped(text string, x, y float64, size float64, col color.Color, maxWidth int, bold bool) float64 {
words := splitWords(text)
if len(words) == 0 {
return y
}
lineHeight := size * 1.3
currentLine := ""
currentY := y
for _, word := range words {
testLine := currentLine
if testLine != "" {
testLine += " "
}
testLine += word
lineWidth := c.MeasureText(testLine, size, bold)
if lineWidth > maxWidth && currentLine != "" {
// Draw current line and start new one
c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
currentY += lineHeight
currentLine = word
} else {
currentLine = testLine
}
}
// Draw remaining text
if currentLine != "" {
c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
currentY += lineHeight
}
return currentY
}
// splitWords splits text into words
func splitWords(text string) []string {
var words []string
current := ""
for _, r := range text {
if r == ' ' || r == '\t' || r == '\n' {
if current != "" {
words = append(words, current)
current = ""
}
} else {
current += string(r)
}
}
if current != "" {
words = append(words, current)
}
return words
}
// DrawImage draws an image at the specified position
func (c *Card) DrawImage(img image.Image, x, y int) {
bounds := img.Bounds()
rect := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy())
draw.Draw(c.img, rect, img, bounds.Min, draw.Over)
}
// DrawCircularImage draws an image cropped to a circle
func (c *Card) DrawCircularImage(img image.Image, x, y, diameter int) {
// Scale image to fit diameter
scaled := scaleImage(img, diameter, diameter)
// Create circular mask
mask := createCircleMask(diameter)
// Draw with mask
rect := image.Rect(x, y, x+diameter, y+diameter)
draw.DrawMask(c.img, rect, scaled, image.Point{}, mask, image.Point{}, draw.Over)
}
// FetchAndDrawCircularImage fetches an image from URL and draws it as a circle
func (c *Card) FetchAndDrawCircularImage(url string, x, y, diameter int) error {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
img, _, err := image.Decode(resp.Body)
if err != nil {
return err
}
c.DrawCircularImage(img, x, y, diameter)
return nil
}
// DrawPlaceholderCircle draws a colored circle with a letter
func (c *Card) DrawPlaceholderCircle(x, y, diameter int, bgColor, textColor color.Color, letter string) {
// Draw filled circle
radius := diameter / 2
centerX := x + radius
centerY := y + radius
for dy := -radius; dy <= radius; dy++ {
for dx := -radius; dx <= radius; dx++ {
if dx*dx+dy*dy <= radius*radius {
c.img.Set(centerX+dx, centerY+dy, bgColor)
}
}
}
// Draw letter in center
fontSize := float64(diameter) * 0.5
c.DrawText(letter, float64(centerX), float64(centerY)+fontSize/3, fontSize, textColor, AlignCenter, true)
}
// DrawRoundedRect draws a filled rounded rectangle
func (c *Card) DrawRoundedRect(x, y, w, h, radius int, col color.Color) {
// Draw main rectangle (without corners)
for dy := range h - 2*radius {
for dx := range w {
c.img.Set(x+dx, y+radius+dy, col)
}
}
// Draw top and bottom strips (without corners)
for dy := range radius {
for dx := range w - 2*radius {
c.img.Set(x+radius+dx, y+dy, col)
c.img.Set(x+radius+dx, y+h-1-dy, col)
}
}
// Draw rounded corners
for dy := range radius {
for dx := range radius {
// Check if point is within circle
cx := radius - dx - 1
cy := radius - dy - 1
if cx*cx+cy*cy <= radius*radius {
// Top-left
c.img.Set(x+dx, y+dy, col)
// Top-right
c.img.Set(x+w-1-dx, y+dy, col)
// Bottom-left
c.img.Set(x+dx, y+h-1-dy, col)
// Bottom-right
c.img.Set(x+w-1-dx, y+h-1-dy, col)
}
}
}
}
// DrawBadge draws a pill-shaped badge with text
func (c *Card) DrawBadge(text string, x, y int, fontSize float64, bgColor, textColor color.Color) int {
// Measure text width
textWidth := c.MeasureText(text, fontSize, false)
paddingX := 12
paddingY := 6
height := int(fontSize) + paddingY*2
width := textWidth + paddingX*2
radius := height / 2
// Draw rounded background
c.DrawRoundedRect(x, y, width, height, radius, bgColor)
// Draw text centered in badge
textX := float64(x + paddingX)
textY := float64(y + paddingY + int(fontSize) - 2)
c.DrawText(text, textX, textY, fontSize, textColor, AlignLeft, false)
return width
}
// EncodePNG encodes the card as PNG to the writer
func (c *Card) EncodePNG(w io.Writer) error {
return png.Encode(w, c.img)
}
// DrawAvatarOrPlaceholder draws a circular avatar from URL, falling back to placeholder
func (c *Card) DrawAvatarOrPlaceholder(url string, x, y, size int, letter string) {
if url != "" {
if err := c.FetchAndDrawCircularImage(url, x, y, size); err == nil {
return
}
}
c.DrawPlaceholderCircle(x, y, size, ColorAccent, ColorText, letter)
}
// DrawStatWithIcon draws an icon + text stat and returns the next X position
func (c *Card) DrawStatWithIcon(icon string, text string, x, y int, iconColor, textColor color.Color) int {
c.DrawIcon(icon, x, y-int(FontStats), int(FontStats), iconColor)
x += StatsIconGap
c.DrawText(text, float64(x), float64(y), FontStats, textColor, AlignLeft, false)
return x + c.MeasureText(text, FontStats, false) + StatsItemGap
}
// DrawBranding draws "ATCR" in the bottom-right corner
func (c *Card) DrawBranding() {
y := CardHeight - Padding - 10
c.DrawText("ATCR", float64(CardWidth-Padding), float64(y), FontBranding, ColorMuted, AlignRight, true)
}
// scaleImage scales an image to the target dimensions
func scaleImage(src image.Image, width, height int) image.Image {
dst := image.NewRGBA(image.Rect(0, 0, width, height))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
return dst
}
// createCircleMask creates a circular alpha mask
func createCircleMask(diameter int) *image.Alpha {
mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
radius := diameter / 2
centerX := radius
centerY := radius
for y := range diameter {
for x := range diameter {
dx := x - centerX
dy := y - centerY
if dx*dx+dy*dy <= radius*radius {
mask.SetAlpha(x, y, color.Alpha{A: 255})
}
}
}
return mask
}
// Common colors
var (
ColorBackground = color.RGBA{R: 22, G: 27, B: 34, A: 255} // #161b22 - GitHub dark elevated
ColorText = color.RGBA{R: 230, G: 237, B: 243, A: 255} // #e6edf3 - Light text
ColorMuted = color.RGBA{R: 125, G: 133, B: 144, A: 255} // #7d8590 - Muted text
ColorAccent = color.RGBA{R: 47, G: 129, B: 247, A: 255} // #2f81f7 - Blue accent
ColorStar = color.RGBA{R: 227, G: 179, B: 65, A: 255} // #e3b341 - Star yellow
ColorBadgeBg = color.RGBA{R: 33, G: 38, B: 45, A: 255} // #21262d - Badge background
ColorBadgeAccent = color.RGBA{R: 31, G: 111, B: 235, A: 255} // #1f6feb - Blue badge bg
)