szabadpalya/api/internal/ogimage/ogimage.go

258 lines
6.6 KiB
Go

// Package ogimage draws the per-campaign share card.
//
// The previous stack rendered this with Satori, which turns JSX into a PNG.
// Nothing equivalent exists in Go, so the card is drawn directly: filled
// rectangles and text on a 1200x630 canvas. The layout is deliberately plain
// — this image is seen at thumbnail size in a feed, where a wrong number
// matters and a missing rounded corner does not.
package ogimage
import (
"bytes"
_ "embed"
"image"
"image/color"
"image/draw"
"image/png"
"strings"
"sync"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
//go:embed Poppins-SemiBold.ttf
var poppinsTTF []byte
const (
Width = 1200
Height = 630
padding = 64
)
var (
electric = color.RGBA{0x73, 0x60, 0xF9, 0xFF}
cream = color.RGBA{0xFF, 0xF1, 0xEB, 0xFF}
lime = color.RGBA{0xB9, 0xD4, 0x40, 0xFF}
lav = color.RGBA{0xBC, 0x9B, 0xF3, 0xFF}
laneBed = color.RGBA{0x2C, 0x23, 0x47, 0xFF}
)
// Faces are expensive to build and safe to share, so each size is created once.
var (
faceMu sync.Mutex
faceCache = map[float64]font.Face{}
parsed *opentype.Font
parseOnce sync.Once
parseErr error
)
func face(size float64) (font.Face, error) {
parseOnce.Do(func() { parsed, parseErr = opentype.Parse(poppinsTTF) })
if parseErr != nil {
return nil, parseErr
}
faceMu.Lock()
defer faceMu.Unlock()
if f, ok := faceCache[size]; ok {
return f, nil
}
// DPI 72 makes one point equal one pixel, so sizes read as pixels.
f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, err
}
faceCache[size] = f
return f, nil
}
// Card is everything the image shows. Formatting happens in the caller so this
// package never has to know about Hungarian number rules.
type Card struct {
Title string
Category string
Creator string
Raised string
GoalNote string
Percent string
// Fraction is the share of the bar that is filled, 0..1, and Over is the
// part beyond the goal, drawn in lime. Both are already scaled.
Fraction float64
Over float64
Backers string
Deadline string
}
// Render draws the card and returns a PNG.
func Render(c Card) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, Width, Height))
fill(img, img.Bounds(), electric)
// Header: mark, wordmark, category.
fill(img, image.Rect(padding, padding, padding+56, padding+56), cream)
x, err := text(img, "Szabadpálya", padding+76, padding+38, 30, cream)
if err != nil {
return nil, err
}
if c.Category != "" {
if _, err := text(img, c.Category, x+24, padding+38, 22, lime); err != nil {
return nil, err
}
}
// Title, wrapped to at most three lines so a long one cannot run into the
// progress bar.
titleFace, err := face(68)
if err != nil {
return nil, err
}
lines := wrap(c.Title, titleFace, Width-2*padding, 3)
y := 246
for _, line := range lines {
if _, err := text(img, line, padding, y, 68, cream); err != nil {
return nil, err
}
y += 76
}
if c.Creator != "" {
if _, err := text(img, c.Creator, padding, y+18, 28, lav); err != nil {
return nil, err
}
}
// Progress bar. The filled part is cream and anything past 100% is lime,
// matching the funding lane on the campaign page.
barTop, barBottom := 430, 456
barWidth := Width - 2*padding
fill(img, image.Rect(padding, barTop, padding+barWidth, barBottom), laneBed)
filled := int(float64(barWidth) * clamp(c.Fraction))
fill(img, image.Rect(padding, barTop, padding+filled, barBottom), cream)
if c.Over > 0 {
over := int(float64(barWidth) * clamp(c.Over))
fill(img, image.Rect(padding+filled, barTop, padding+filled+over, barBottom), lime)
}
// Numbers. Laid out left to right with measured widths so nothing overlaps
// when a figure grows a digit.
x = padding
if x, err = text(img, c.Raised, x, 522, 52, cream); err != nil {
return nil, err
}
if _, err := text(img, c.GoalNote, padding, 558, 22, lav); err != nil {
return nil, err
}
x += 44
if x, err = text(img, c.Percent, x, 522, 52, lime); err != nil {
return nil, err
}
x += 44
if x, err = text(img, c.Backers, x, 522, 26, cream); err != nil {
return nil, err
}
x += 36
if _, err = text(img, c.Deadline, x, 522, 26, cream); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Fallback is the card for a slug that does not resolve: the wordmark alone,
// so a stale share link still produces something branded rather than a broken
// image icon.
func Fallback() ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, Width, Height))
fill(img, img.Bounds(), electric)
f, err := face(64)
if err != nil {
return nil, err
}
w := font.MeasureString(f, "Szabadpálya").Ceil()
if _, err := text(img, "Szabadpálya", (Width-w)/2, Height/2, 64, cream); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func fill(img *image.RGBA, r image.Rectangle, c color.Color) {
draw.Draw(img, r.Intersect(img.Bounds()), &image.Uniform{C: c}, image.Point{}, draw.Src)
}
// text draws a string with its baseline at y and returns the x it ended at, so
// the caller can place the next run without guessing a width.
func text(img *image.RGBA, s string, x, y int, size float64, c color.Color) (int, error) {
f, err := face(size)
if err != nil {
return x, err
}
d := &font.Drawer{
Dst: img,
Src: &image.Uniform{C: c},
Face: f,
Dot: fixed.P(x, y),
}
d.DrawString(s)
return d.Dot.X.Ceil(), nil
}
// wrap breaks text on word boundaries to fit maxWidth, giving up after
// maxLines and ending the last one with an ellipsis.
func wrap(s string, f font.Face, maxWidth, maxLines int) []string {
words := strings.Fields(s)
if len(words) == 0 {
return nil
}
var lines []string
current := words[0]
for _, w := range words[1:] {
candidate := current + " " + w
if font.MeasureString(f, candidate).Ceil() <= maxWidth {
current = candidate
continue
}
lines = append(lines, current)
if len(lines) == maxLines {
return truncateLast(lines, f, maxWidth)
}
current = w
}
lines = append(lines, current)
if len(lines) > maxLines {
lines = lines[:maxLines]
return truncateLast(lines, f, maxWidth)
}
return lines
}
func truncateLast(lines []string, f font.Face, maxWidth int) []string {
last := lines[len(lines)-1]
for font.MeasureString(f, last+"…").Ceil() > maxWidth && len(last) > 0 {
last = strings.TrimRight(last[:len(last)-1], " ")
}
lines[len(lines)-1] = last + "…"
return lines
}
func clamp(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}