// Package ogcard draws a repository's link-preview card: the image a
// chat app or a social site shows when someone pastes a repository's
// address (its og:image). 1200x630, the size every unfurler crops to.
//
// Drawn in Go rather than by a browser, so the forge renders one with
// no headless Chrome and no network: IBM Plex Sans (the forge's own
// face, here as static TTFs because x/image cannot read the WOFF2 the
// pages use), the repository's avatar or identicon, and its colour.
package ogcard
import (
"bytes"
"embed"
"fmt"
"image"
"image/color"
"image/png"
"math"
"strconv"
"strings"
"sync"
"golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/image/vector"
)
// W and H are the card's size: the 1.91:1 frame unfurlers expect.
const (
W = 1200
H = 630
)
//go:embed fonts/*.ttf
var fontFiles embed.FS
// lockupPNG is the Zeitforge lockup, the mark and the ZEITFORGE
// wordmark as the page header draws them (cream, FORGE in the muted
// brown), rendered once from the header's SVG to a transparent PNG at
// the size the card shows it: the SVG's arcs and even-odd cut-outs are
// not worth a second renderer. Its transparent margin is trimmed when it
// is loaded.
//
//go:embed lockup.png
var lockupPNG []byte
// Stat is one figure on the card: "21 check-ins".
type Stat struct {
N int
One string // the label for 1
Many string // the label otherwise
}
// Card is what a repository's preview shows.
type Card struct {
Owner string // may be empty: a repository with no owner path
Name string
Description string
Stats []Stat
// Avatar is the repository's uploaded image, or nil for its
// identicon, which Grid and Color draw.
Avatar image.Image
// Builtin names a mark the forge draws for a repository before
// anybody uploads one (the page's builtinMark): "quarry" is quarry's
// logo. Empty means the identicon.
Builtin string
Grid [5][5]uint8
Color color.RGBA // the repository's colour: identicon and stripe
}
// The forge's dark theme.
var (
bg = color.RGBA{0x1d, 0x20, 0x21, 0xff}
ink = color.RGBA{0xf2, 0xe5, 0xbc, 0xff}
muted = color.RGBA{0xa8, 0x99, 0x84, 0xff}
)
var (
parseOnce sync.Once
faces map[string]*opentype.Font
lockup image.Image
parseErr error
)
func loadFonts() error {
parseOnce.Do(func() {
faces = map[string]*opentype.Font{}
for _, w := range []string{"Regular", "SemiBold", "Bold"} {
b, err := fontFiles.ReadFile("fonts/IBMPlexSans-" + w + ".ttf")
if err != nil {
parseErr = err
return
}
f, err := opentype.Parse(b)
if err != nil {
parseErr = err
return
}
faces[w] = f
}
img, err := png.Decode(bytes.NewReader(lockupPNG))
if err != nil {
parseErr = err
return
}
lockup = trimmed(img)
})
return parseErr
}
func face(weight string, size float64) (font.Face, error) {
return opentype.NewFace(faces[weight], &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
}
// Render draws the card as a PNG.
func Render(c Card) ([]byte, error) {
if err := loadFonts(); err != nil {
return nil, fmt.Errorf("ogcard: fonts: %w", err)
}
img := image.NewRGBA(image.Rect(0, 0, W, H))
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
const left, textW = 80, 760
y := 150
// owner/ above the name, the way the address reads.
if c.Owner != "" {
f, err := face("Regular", 52)
if err != nil {
return nil, err
}
text(img, f, left, y, fit(f, c.Owner+"/", textW), muted)
y += 82
}
// The name, as large as fits.
size := 84.0
nf, err := face("Bold", size)
if err != nil {
return nil, err
}
for width(nf, c.Name) > textW && size > 44 {
size -= 4
if nf, err = face("Bold", size); err != nil {
return nil, err
}
}
text(img, nf, left, y, fit(nf, c.Name, textW), ink)
y += 70
if d := strings.Join(strings.Fields(c.Description), " "); d != "" {
df, err := face("Regular", 32)
if err != nil {
return nil, err
}
for _, line := range wrap(df, d, textW, 3) {
text(img, df, left, y, line, muted)
y += 46
}
}
// The figures, along the bottom: the number bright, its word quiet.
// They share the bottom line with the lockup, so they get the room
// left of it: a size smaller when they need it, and the last figure
// dropped only when even the smallest does not fit.
limit := W - 80 - lockup.Bounds().Dx() - 48 - left
stats := c.Stats
for len(stats) > 0 {
fitted := false
for _, size := range []float64{32, 30, 28, 26} {
nfig, err := face("SemiBold", size)
if err != nil {
return nil, err
}
lfig, err := face("Regular", size)
if err != nil {
return nil, err
}
gap := int(size * 1.5)
total := 0
for i, s := range stats {
n, label := figure(s)
total += width(nfig, n) + 10 + width(lfig, label)
if i > 0 {
total += gap
}
}
if total > limit {
continue
}
x := left
for _, s := range stats {
n, label := figure(s)
text(img, nfig, x, 540, n, ink)
x += width(nfig, n) + 10
text(img, lfig, x, 540, label, muted)
x += width(lfig, label) + gap
}
fitted = true
break
}
if fitted {
break
}
stats = stats[:len(stats)-1]
}
// The mark, top right.
const box = 220
at := image.Rect(W-80-box, 80, W-80, 80+box)
switch {
case c.Avatar != nil:
drawAvatar(img, at, c.Avatar)
case c.Builtin == "quarry":
drawQuarryMark(img, at)
default:
drawIdenticon(img, at, c.Grid, c.Color)
}
// Zeitforge, bottom right, centred on the figures' line.
lb := lockup.Bounds()
at = image.Rect(W-80-lb.Dx(), 529-lb.Dy()/2, W-80, 529-lb.Dy()/2+lb.Dy())
draw.Draw(img, at, lockup, lb.Min, draw.Over)
// The repository's colour along the bottom edge.
draw.Draw(img, image.Rect(0, H-16, W, H), &image.Uniform{c.Color}, image.Point{}, draw.Src)
var out bytes.Buffer
if err := (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(&out, img); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// figure is a stat's number and the word for it.
func figure(s Stat) (string, string) {
if s.N == 1 {
return strconv.Itoa(s.N), s.One
}
return strconv.Itoa(s.N), s.Many
}
func text(dst *image.RGBA, f font.Face, x, y int, s string, c color.RGBA) {
d := font.Drawer{Dst: dst, Src: &image.Uniform{c}, Face: f, Dot: fixed.P(x, y)}
d.DrawString(s)
}
func width(f font.Face, s string) int {
return font.MeasureString(f, s).Ceil()
}
// fit cuts s to max pixels, with an ellipsis when it had to.
func fit(f font.Face, s string, max int) string {
if width(f, s) <= max {
return s
}
r := []rune(s)
for len(r) > 0 && width(f, string(r)+"…") > max {
r = r[:len(r)-1]
}
return strings.TrimRight(string(r), " ") + "…"
}
// wrap breaks s into at most lines lines of max pixels, the last cut
// with an ellipsis when the text runs on.
func wrap(f font.Face, s string, max, lines int) []string {
var out []string
words := strings.Fields(s)
cur := ""
for i, w := range words {
try := w
if cur != "" {
try = cur + " " + w
}
if width(f, try) <= max || cur == "" {
cur = try
continue
}
out = append(out, cur)
cur = w
if len(out) == lines-1 {
rest := strings.Join(words[i:], " ")
return append(out, fit(f, rest, max))
}
}
if cur != "" {
out = append(out, fit(f, cur, max))
}
return out
}
// drawAvatar scales img into r with rounded corners.
func drawAvatar(dst *image.RGBA, r image.Rectangle, src image.Image) {
scaled := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, src.Bounds(), draw.Over, nil)
draw.DrawMask(dst, r, scaled, image.Point{}, rounded(r.Dx(), r.Dy(), 28), image.Point{}, draw.Over)
}
// drawQuarryMark draws quarry's logo as the page's builtinMark draws it,
// in its 24-unit box (x from -1): a faint tile in the text colour, a
// ring of radius 7.2 and stroke 2.8 round (11,12), a dot of radius 2.2
// at its centre, and the handle from (17.1,18.1) to (19.2,20.2) with
// round caps.
func drawQuarryMark(dst *image.RGBA, r image.Rectangle) {
draw.DrawMask(dst, r, &image.Uniform{blend(bg, ink, 0.14)}, image.Point{}, rounded(r.Dx(), r.Dy(), 28), image.Point{}, draw.Over)
k := float32(r.Dx()) / 24
px := func(x, y float32) (float32, float32) { return (x + 1) * k, y * k }
z := vector.NewRasterizer(r.Dx(), r.Dy())
circle := func(cx, cy, rad float32, clockwise bool) {
const n = 96
for i := 0; i <= n; i++ {
a := 2 * math.Pi * float64(i) / n
if !clockwise {
a = -a
}
x, y := px(cx+rad*float32(math.Cos(a)), cy+rad*float32(math.Sin(a)))
if i == 0 {
z.MoveTo(x, y)
} else {
z.LineTo(x, y)
}
}
z.ClosePath()
}
// The ring: the outer edge one way round, the inner the other, so
// the winding leaves the middle empty.
circle(11, 12, 7.2+1.4, true)
circle(11, 12, 7.2-1.4, false)
circle(11, 12, 2.2, true)
// The handle: a stroke 2.8 wide along its line, with round ends.
x0, y0, x1, y1 := float32(17.1), float32(18.1), float32(19.2), float32(20.2)
dx, dy := x1-x0, y1-y0
l := float32(math.Hypot(float64(dx), float64(dy)))
nx, ny := -dy/l*1.4, dx/l*1.4
quad := [][2]float32{{x0 + nx, y0 + ny}, {x1 + nx, y1 + ny}, {x1 - nx, y1 - ny}, {x0 - nx, y0 - ny}}
// Wound the way the filled circles are, or where the handle meets
// the ring and its own end caps the two would cancel to a hole.
area := float32(0)
for i := range quad {
a, b := quad[i], quad[(i+1)%len(quad)]
area += a[0]*b[1] - b[0]*a[1]
}
if area < 0 {
quad[1], quad[3] = quad[3], quad[1]
}
for i, p := range quad {
x, y := px(p[0], p[1])
if i == 0 {
z.MoveTo(x, y)
} else {
z.LineTo(x, y)
}
}
z.ClosePath()
circle(x0, y0, 1.4, true)
circle(x1, y1, 1.4, true)
z.Draw(dst, r, &image.Uniform{ink}, image.Point{})
}
// drawIdenticon draws the 5x5 face Mark draws in SVG: a wash of the
// colour, lit cells in it and deep cells darker.
func drawIdenticon(dst *image.RGBA, r image.Rectangle, g [5][5]uint8, c color.RGBA) {
wash := blend(bg, c, 0.18)
draw.DrawMask(dst, r, &image.Uniform{wash}, image.Point{}, rounded(r.Dx(), r.Dy(), 28), image.Point{}, draw.Over)
deep := blend(c, color.RGBA{0, 0, 0, 0xff}, 0.25)
pad := r.Dx() / 10
cell := (r.Dx() - 2*pad) / 5
for row := 0; row < 5; row++ {
for col := 0; col < 5; col++ {
if g[row][col] == 0 {
continue
}
fill := c
if g[row][col] == 2 {
fill = deep
}
x0, y0 := r.Min.X+pad+col*cell, r.Min.Y+pad+row*cell
draw.Draw(dst, image.Rect(x0, y0, x0+cell, y0+cell), &image.Uniform{fill}, image.Point{}, draw.Src)
}
}
}
// trimmed is img cut to the box its opaque pixels occupy.
func trimmed(img image.Image) image.Image {
b := img.Bounds()
box := image.Rectangle{}
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if _, _, _, a := img.At(x, y).RGBA(); a > 0x0800 {
box = box.Union(image.Rect(x, y, x+1, y+1))
}
}
}
if box.Empty() {
return img
}
out := image.NewRGBA(image.Rect(0, 0, box.Dx(), box.Dy()))
draw.Draw(out, out.Bounds(), img, box.Min, draw.Src)
return out
}
func blend(a, b color.RGBA, t float64) color.RGBA {
m := func(x, y uint8) uint8 { return uint8(float64(x)*(1-t) + float64(y)*t) }
return color.RGBA{m(a.R, b.R), m(a.G, b.G), m(a.B, b.B), 0xff}
}
// rounded is an alpha mask of a w x h rectangle with corners of radius r.
func rounded(w, h, r int) *image.Alpha {
m := image.NewAlpha(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
cx, cy := x, y
switch {
case x < r && y < r:
cx, cy = r-x, r-y
case x >= w-r && y < r:
cx, cy = x-(w-r-1), r-y
case x < r && y >= h-r:
cx, cy = r-x, y-(h-r-1)
case x >= w-r && y >= h-r:
cx, cy = x-(w-r-1), y-(h-r-1)
default:
m.SetAlpha(x, y, color.Alpha{0xff})
continue
}
if cx*cx+cy*cy <= r*r {
m.SetAlpha(x, y, color.Alpha{0xff})
}
}
}
return m
}
// ParseHex reads "#rrggbb", as idn.Hex writes it.
func ParseHex(s string) color.RGBA {
var r, g, b uint8
if _, err := fmt.Sscanf(strings.TrimPrefix(s, "#"), "%02x%02x%02x", &r, &g, &b); err != nil {
return muted
}
return color.RGBA{r, g, b, 0xff}
}