a73x

internal/site/og.go

Ref:   Size: 4.0 KiB   History

// og.go draws the link-preview card — the image a chat client or a timeline
// shows when someone pastes an eitri.sh link. It is drawn from the same
// GohuFont pixels as the mark, at build time, so a card can never disagree
// with the site it came from.

package site

import (
	"bytes"
	"fmt"
	"image"
	"image/color"
	"image/png"
)

// Card geometry, in image pixels. 1200x630 is what every scraper crops to.
const (
	cardW, cardH = 1200, 630
	cardInset    = 110 // left edge of the block
	cardRuleW    = 260
	cardRuleH    = 4

	// Scale factors: how many image pixels one font pixel becomes.
	scaleMark     = 12
	scaleWordmark = 6
	scaleTagline  = 3
	scaleLabel    = 4
)

// The card's fixed copy. The label under the rule is the page's own.
const (
	cardWordmark = "eitri.sh"
	cardTagline  = "the cloud you already own"
)

// Palette indices: a three-colour card encodes to a few KB.
const (
	cardBG = iota
	cardFG
	cardDim
)

var cardPalette = color.Palette{
	mustParseHex(inkLight), // background: the ink colour, inverted card
	mustParseHex(inkDark),  // foreground
	color.RGBA{0x8a, 0x8a, 0x8a, 0xff},
}

func mustParseHex(s string) color.RGBA {
	var r, g, b uint8
	if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err != nil {
		panic("site: bad colour " + s)
	}
	return color.RGBA{r, g, b, 0xff}
}

// renderCard draws the card for one page. label names the page under the
// rule; empty leaves the block at mark, wordmark and tagline — the landing
// page needs no label, it is the site.
func renderCard(label string) ([]byte, error) {
	font, err := parseBDF(gohu14b)
	if err != nil {
		return nil, err
	}
	mark, err := markBitmap(font)
	if err != nil {
		return nil, err
	}

	// Lay the block out first so it can be centred as a whole.
	type row struct {
		gap  int // space above this row
		h    int
		draw func(img *image.Paletted, y int) error
	}
	line := func(gap int, s string, scale, ink int) row {
		return row{gap: gap, h: font.lineHeight() * scale, draw: func(img *image.Paletted, y int) error {
			runs, err := font.runs(s)
			if err != nil {
				return err
			}
			w, err := font.width(s)
			if err != nil {
				return err
			}
			// Text that overruns the card would be cropped by the scraper,
			// silently: fail the build instead.
			if room := cardW - 2*cardInset; w*scale > room {
				return fmt.Errorf("%q is %dpx wide at scale %d, past the %dpx the card leaves", s, w*scale, scale, room)
			}
			drawRuns(img, runs, cardInset, y, scale, uint8(ink))
			return nil
		}}
	}
	rows := []row{
		{h: len(mark) * scaleMark, draw: func(img *image.Paletted, y int) error {
			drawRuns(img, bitmapRuns(mark), cardInset, y, scaleMark, cardFG)
			return nil
		}},
		line(44, cardWordmark, scaleWordmark, cardFG),
		line(20, cardTagline, scaleTagline, cardDim),
	}
	if label != "" {
		rows = append(rows,
			row{gap: 44, h: cardRuleH, draw: func(img *image.Paletted, y int) error {
				fillRect(img, cardInset, y, cardRuleW, cardRuleH, cardDim)
				return nil
			}},
			line(28, label, scaleLabel, cardFG))
	}

	total := 0
	for _, r := range rows {
		total += r.gap + r.h
	}
	if total > cardH {
		return nil, fmt.Errorf("card block is %dpx tall, taller than the %dpx card", total, cardH)
	}

	img := image.NewPaletted(image.Rect(0, 0, cardW, cardH), cardPalette)
	y := (cardH - total) / 2
	for _, r := range rows {
		y += r.gap
		if err := r.draw(img, y); err != nil {
			return nil, err
		}
		y += r.h
	}

	var buf bytes.Buffer
	if err := png.Encode(&buf, img); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// drawRuns paints font-pixel runs at scale, with the run's own origin taken
// as (ox, oy) in image pixels.
func drawRuns(img *image.Paletted, runs []textRun, ox, oy, scale int, ink uint8) {
	for _, r := range runs {
		fillRect(img, ox+r.X*scale, oy+r.Y*scale, r.W*scale, scale, ink)
	}
}

func fillRect(img *image.Paletted, x, y, w, h int, ink uint8) {
	for row := y; row < y+h; row++ {
		for col := x; col < x+w; col++ {
			if (image.Point{col, row}).In(img.Rect) {
				img.SetColorIndex(col, row, ink)
			}
		}
	}
}