6a70135a5d
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>
225 lines
7.4 KiB
Go
225 lines
7.4 KiB
Go
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())
|
|
}
|