258 lines
6.6 KiB
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
|
|
}
|