Skip to content
quarry Artifact [6888a4a432]
Login

Artifact [6888a4a432]

Artifact 6888a4a43283c7c1cc353785663386c14dbd2f2c87f939d71e9b8f69ad7daaf9:


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

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