Skip to content
quarry Artifact [57cd80ab12]
Login

Artifact [57cd80ab12]

Artifact 57cd80ab124ebbe0d316eba883d0be44231b52288df337261cf4fceaccc672cf:


// 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"
	"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"
)

// 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

// 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
	Grid   [5][5]uint8
	Color  color.RGBA // the repository's colour: identicon and stripe
	Site   string     // the forge's host name, bottom right
}

// 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
	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
		}
	})
	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.
	nfig, err := face("SemiBold", 32)
	if err != nil {
		return nil, err
	}
	lfig, err := face("Regular", 32)
	if err != nil {
		return nil, err
	}
	x := left
	for _, s := range c.Stats {
		n := strconv.Itoa(s.N)
		label := s.Many
		if s.N == 1 {
			label = s.One
		}
		text(img, nfig, x, 540, n, ink)
		x += width(nfig, n) + 10
		text(img, lfig, x, 540, label, muted)
		x += width(lfig, label) + 48
	}

	// The mark, top right.
	const box = 220
	at := image.Rect(W-80-box, 80, W-80, 80+box)
	if c.Avatar != nil {
		drawAvatar(img, at, c.Avatar)
	} else {
		drawIdenticon(img, at, c.Grid, c.Color)
	}

	if c.Site != "" {
		sf, err := face("SemiBold", 28)
		if err != nil {
			return nil, err
		}
		text(img, sf, W-80-width(sf, c.Site), 540, c.Site, muted)
	}

	// 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
}

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)
}

// 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)
		}
	}
}

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}
}