Add dynamic OG image generation for rich iMessage/social previews
Generates a branded 1200x630 PNG per event with title, date/time/location using Go's image package and a custom bitmap font. Supports BBQ_BASE_URL env var for absolute URLs required by link preview crawlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ogCream = color.RGBA{0xf5, 0xf0, 0xe8, 0xff}
|
||||
ogInk = color.RGBA{0x1a, 0x1a, 0x1a, 0xff}
|
||||
ogYellow = color.RGBA{0xf5, 0xe6, 0x42, 0xff}
|
||||
ogSage = color.RGBA{0xa8, 0xc5, 0xa0, 0xff}
|
||||
ogPeach = color.RGBA{0xf2, 0xa4, 0x82, 0xff}
|
||||
ogWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||
)
|
||||
|
||||
func fillRect(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
|
||||
draw.Draw(img, image.Rect(x1, y1, x2, y2), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
// 5x7 pixel font for uppercase letters, digits, and a few symbols
|
||||
var glyphs = map[rune][7]uint8{
|
||||
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
|
||||
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
|
||||
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
|
||||
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
|
||||
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110},
|
||||
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'I': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
|
||||
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
|
||||
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
|
||||
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
|
||||
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
|
||||
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
|
||||
'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
|
||||
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
|
||||
'S': {0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110},
|
||||
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
|
||||
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001},
|
||||
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
|
||||
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
|
||||
'0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110},
|
||||
'1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
|
||||
'2': {0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111},
|
||||
'3': {0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110},
|
||||
'4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010},
|
||||
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
|
||||
'6': {0b01110, 0b10000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
|
||||
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
|
||||
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
|
||||
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110},
|
||||
' ': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000},
|
||||
'\'': {0b00100, 0b00100, 0b01000, 0b00000, 0b00000, 0b00000, 0b00000},
|
||||
'.': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100},
|
||||
',': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b01000},
|
||||
'!': {0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100},
|
||||
'?': {0b01110, 0b10001, 0b00001, 0b00110, 0b00100, 0b00000, 0b00100},
|
||||
'-': {0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000},
|
||||
'#': {0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010},
|
||||
'/': {0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000},
|
||||
':': {0b00000, 0b00100, 0b00000, 0b00000, 0b00000, 0b00100, 0b00000},
|
||||
'@': {0b01110, 0b10001, 0b10111, 0b10101, 0b10110, 0b10000, 0b01110},
|
||||
'+': {0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000},
|
||||
'&': {0b01100, 0b10010, 0b10100, 0b01000, 0b10101, 0b10010, 0b01101},
|
||||
}
|
||||
|
||||
func drawText(img *image.RGBA, text string, x, y, scale int, c color.RGBA) int {
|
||||
cx := x
|
||||
for _, ch := range text {
|
||||
if ch >= 'a' && ch <= 'z' {
|
||||
ch = ch - 'a' + 'A'
|
||||
}
|
||||
glyph, ok := glyphs[ch]
|
||||
if !ok {
|
||||
cx += 4 * scale // skip unknown chars
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 7; row++ {
|
||||
for col := 0; col < 5; col++ {
|
||||
if glyph[row]&(1<<(4-col)) != 0 {
|
||||
fillRect(img, cx+col*scale, y+row*scale, cx+(col+1)*scale, y+(row+1)*scale, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += 6 * scale // 5 pixels + 1 spacing
|
||||
}
|
||||
return cx - x
|
||||
}
|
||||
|
||||
func textWidth(text string, scale int) int {
|
||||
n := 0
|
||||
for range text {
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return n*6*scale - scale
|
||||
}
|
||||
|
||||
func (s *Server) handleOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
imgW, imgH := 1200, 630
|
||||
img := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
|
||||
|
||||
// Background
|
||||
fillRect(img, 0, 0, imgW, imgH, ogCream)
|
||||
|
||||
// Yellow header bar
|
||||
fillRect(img, 0, 0, imgW, 80, ogYellow)
|
||||
fillRect(img, 0, 78, imgW, 83, ogInk)
|
||||
|
||||
// Logo in header: dark box with emoji-colored inner
|
||||
fillRect(img, 40, 16, 88, 64, ogInk)
|
||||
fillRect(img, 44, 20, 84, 60, ogYellow)
|
||||
drawText(img, "BBQ", 100, 26, 4, ogInk)
|
||||
|
||||
// Center card
|
||||
cardX, cardY := 120, 140
|
||||
cardW, cardH := imgW-240, 400
|
||||
|
||||
// Shadow
|
||||
fillRect(img, cardX+8, cardY+8, cardX+cardW+8, cardY+cardH+8, ogInk)
|
||||
// Border
|
||||
fillRect(img, cardX-3, cardY-3, cardX+cardW+3, cardY+cardH+3, ogInk)
|
||||
// Card
|
||||
fillRect(img, cardX, cardY, cardX+cardW, cardY+cardH, ogWhite)
|
||||
|
||||
// Tag pill
|
||||
tagText := "POTLUCK"
|
||||
tagW := textWidth(tagText, 3) + 24
|
||||
fillRect(img, cardX+30-2, cardY+28-2, cardX+30+tagW+2, cardY+28+28+2, ogInk)
|
||||
fillRect(img, cardX+30, cardY+28, cardX+30+tagW, cardY+28+28, ogSage)
|
||||
drawText(img, tagText, cardX+42, cardY+32, 3, ogInk)
|
||||
|
||||
// Event title - scale to fit
|
||||
title := event.Title
|
||||
scale := 7
|
||||
for scale > 3 {
|
||||
if textWidth(title, scale) <= cardW-80 {
|
||||
break
|
||||
}
|
||||
scale--
|
||||
}
|
||||
// Truncate if still too wide
|
||||
for len(title) > 0 && textWidth(title, scale) > cardW-80 {
|
||||
title = title[:len(title)-1]
|
||||
}
|
||||
titleY := cardY + 90
|
||||
drawText(img, title, cardX+38, titleY, scale, ogInk)
|
||||
|
||||
// Date/time/location line
|
||||
meta := ""
|
||||
if event.Date != "" {
|
||||
meta = event.Date
|
||||
}
|
||||
if event.Time != "" {
|
||||
if meta != "" {
|
||||
meta += " "
|
||||
}
|
||||
meta += event.Time
|
||||
}
|
||||
if event.Location != "" {
|
||||
if meta != "" {
|
||||
meta += " - "
|
||||
}
|
||||
meta += event.Location
|
||||
}
|
||||
if meta != "" {
|
||||
metaScale := 3
|
||||
for len(meta) > 0 && textWidth(meta, metaScale) > cardW-80 {
|
||||
meta = meta[:len(meta)-1]
|
||||
}
|
||||
metaY := titleY + 7*scale + 24
|
||||
drawText(img, meta, cardX+38, metaY, metaScale, color.RGBA{0x66, 0x66, 0x66, 0xff})
|
||||
}
|
||||
|
||||
// Decorative slot-like bars at the bottom of the card
|
||||
barY := cardY + cardH - 100
|
||||
bars := []struct {
|
||||
w int
|
||||
c color.RGBA
|
||||
}{
|
||||
{320, ogYellow},
|
||||
{240, ogSage},
|
||||
{280, ogPeach},
|
||||
{200, ogYellow},
|
||||
}
|
||||
for i, b := range bars {
|
||||
y := barY + i*22
|
||||
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
|
||||
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
|
||||
}
|
||||
|
||||
// Bottom accent stripe
|
||||
fillRect(img, 0, imgH-14, imgW, imgH-10, ogInk)
|
||||
fillRect(img, 0, imgH-10, imgW, imgH, ogYellow)
|
||||
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, img)
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user