mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
417 lines
11 KiB
Go
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
|
|
)
|