Files

283 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package build
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg" // a logo can be a JPEG
"image/png"
"os"
"path"
"path/filepath"
"strconv"
"strings"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Social cards: the picture a link preview shows (Mastodon, LinkedIn,
// Bluesky, chat apps), drawn at build time for every page that doesn't name
// its own image. 1200×630, the page's title large, the site's name small, in
// the site's colors. Drawn in Go with the Go fonts unless the site brings its
// own, so the build needs nothing installed. The file name carries a hash of
// what's on the card, so a changed title is a new address and previews
// refresh.
const cardW, cardH = 1200, 630
// cardPath is where a page's card is published, or "" if it gets none.
func cardPath(s *site.Site, p *site.Page) string {
cc := s.Config.Cards
if !cc.On() || p.Image != "" || p.RedirectTo != "" || p.NoIndex || (p.Kind != site.KindPage && p.Kind != site.KindList) {
return ""
}
name := strings.Trim(strings.ReplaceAll(p.Path, "/", "-"), "-")
name = strings.TrimSuffix(name, ".html")
if name == "" {
name = "home"
}
sum := sha256.Sum256([]byte(p.Title + "\x00" + p.Summary + "\x00" + s.Config.Name + "\x00" + cc.Signature()))
return "/cards/" + name + "." + hex.EncodeToString(sum[:4]) + ".png"
}
// assignCards points every page that will get a card at it, so templates
// write og:image for it, and returns the cards to draw. The live preview does
// this too, without drawing.
func assignCards(s *site.Site) map[*site.Page]string {
cards := map[*site.Page]string{}
for _, p := range s.Pages {
if c := cardPath(s, p); c != "" {
cards[p] = c
p.Image = c
if p.ImageAlt == "" {
p.ImageAlt = p.Title
}
}
}
return cards
}
type cardStyle struct {
bg, fg, accent color.Color
title, small *opentype.Font
logo image.Image
}
func parseHex(h string, def color.Color) color.Color {
h = strings.TrimPrefix(h, "#")
if len(h) == 3 {
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
}
v, err := strconv.ParseUint(h, 16, 32)
if len(h) != 6 || err != nil {
return def
}
return color.RGBA{uint8(v >> 16), uint8(v >> 8), uint8(v), 255}
}
func loadCardStyle(siteDir string, s *site.Site) (*cardStyle, error) {
cc := s.Config.Cards
// Colors: the card's own, else the site's look (band and its text, which
// read well large), else a dark default.
look := map[string]string{}
if lf, err := site.LoadLook(siteDir); err == nil && lf != nil {
vals, _ := lf.Values(s.Config.Look)
for _, t := range lf.Tokens {
look[t.Name] = lf.Get(vals, t.Name)
}
}
pick := func(own string, names ...string) string {
if own != "" {
return own
}
for _, n := range names {
if look[n] != "" {
return look[n]
}
}
return ""
}
st := &cardStyle{
bg: parseHex(pick(cc.Background, "band", "text"), color.RGBA{0x1d, 0x2b, 0x3a, 255}),
fg: parseHex(pick(cc.Text, "band-text", "bg"), color.RGBA{0xf5, 0xf1, 0xea, 255}),
accent: parseHex(pick(cc.Accent, "accent"), color.RGBA{0xf4, 0xbe, 0x2c, 255}),
}
var err error
if st.title, err = loadFont(siteDir, cc.Font, gobold.TTF); err != nil {
return nil, err
}
if st.small, err = loadFont(siteDir, cc.FontSmall, goregular.TTF); err != nil {
return nil, err
}
logo := cc.Logo
if logo == "" {
logo = look["logo"]
}
if logo != "" && !strings.HasSuffix(strings.ToLower(logo), ".svg") {
for _, base := range []string{"static", "assets"} {
data, err := site.ReadFile(siteDir, path.Join(base, path.Clean("/"+logo)), 16<<20)
if err != nil {
continue
}
// Check the size from the header before decoding: a small file
// can claim a huge picture.
if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil && cfg.Width > 0 && cfg.Height > 0 && cfg.Width*cfg.Height <= maxLogoPixels {
st.logo, _, _ = image.Decode(bytes.NewReader(data))
}
break
}
}
return st, nil
}
// maxLogoPixels bounds a card logo: far more than any logo needs.
const maxLogoPixels = 16_000_000
func loadFont(siteDir, rel string, fallback []byte) (*opentype.Font, error) {
data := fallback
if rel != "" {
b, err := site.ReadFile(siteDir, rel, 16<<20)
if err != nil {
return nil, fmt.Errorf("cards: font %s: %w", rel, err)
}
data = b
}
f, err := opentype.Parse(data)
if err != nil {
return nil, fmt.Errorf("cards: font %s: %w", rel, err)
}
return f, nil
}
func face(f *opentype.Font, size float64) font.Face {
fc, _ := opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
return fc
}
// wrap breaks text into lines no wider than width.
func wrap(fc font.Face, text string, width int) []string {
var lines []string
line := ""
for _, w := range strings.Fields(text) {
try := strings.TrimSpace(line + " " + w)
if font.MeasureString(fc, try).Ceil() > width && line != "" {
lines = append(lines, line)
line = w
} else {
line = try
}
}
if line != "" {
lines = append(lines, line)
}
return lines
}
// drawCard renders one card.
func drawCard(st *cardStyle, title, summary, siteName string) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, cardW, cardH))
draw.Draw(img, img.Bounds(), &image.Uniform{st.bg}, image.Point{}, draw.Src)
// The accent: a bar down the left edge.
draw.Draw(img, image.Rect(0, 0, 18, cardH), &image.Uniform{st.accent}, image.Point{}, draw.Src)
const margin = 90
width := cardW - 2*margin
// The largest size at which the title fits in four lines.
size := 76.0
var fc font.Face
var lines []string
for ; size >= 40; size -= 4 {
fc = face(st.title, size)
lines = wrap(fc, title, width)
if len(lines) <= 4 {
break
}
}
if len(lines) > 4 {
lines = append(lines[:3], strings.TrimSpace(lines[3])+"…")
lines = lines[:4]
}
lineH := int(size * 1.18)
top := margin + 40
if st.logo != nil {
top += 30
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{st.fg}, Face: fc}
for i, l := range lines {
d.Dot = fixed.P(margin, top+int(size)+i*lineH)
d.DrawString(l)
}
// The summary, softer, under a title short enough to leave room.
if summary != "" && len(lines) <= 2 {
sf := face(st.small, 34)
r, g, b, _ := st.fg.RGBA()
br, bg, bb, _ := st.bg.RGBA()
soft := color.RGBA{uint8((r*3 + br) / 4 >> 8), uint8((g*3 + bg) / 4 >> 8), uint8((b*3 + bb) / 4 >> 8), 255}
ds := &font.Drawer{Dst: img, Src: &image.Uniform{soft}, Face: sf}
y := top + int(size) + (len(lines)-1)*lineH + 70
sl := wrap(sf, summary, width)
if len(sl) > 3 {
sl = append(sl[:2], strings.TrimSpace(sl[2])+"…")
}
for i, l := range sl {
ds.Dot = fixed.P(margin, y+i*46)
ds.DrawString(l)
}
}
small := face(st.small, 34)
ds := &font.Drawer{Dst: img, Src: &image.Uniform{st.fg}, Face: small}
x := margin
if st.logo != nil {
// The logo, 56 px tall, before the site's name at the bottom.
b := st.logo.Bounds()
w := b.Dx() * 56 / max(1, b.Dy())
r := image.Rect(margin, cardH-margin-56+8, margin+w, cardH-margin+8)
xdraw.CatmullRom.Scale(img, r, st.logo, b, xdraw.Over, nil)
x += w + 18
}
ds.Dot = fixed.P(x, cardH-margin)
ds.DrawString(siteName)
var buf bytes.Buffer
if err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// writeCards draws the cards assignCards gave out.
func writeCards(siteDir, out string, s *site.Site, cards map[*site.Page]string) error {
if len(cards) == 0 {
return nil
}
st, err := loadCardStyle(siteDir, s)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(out, "cards"), 0o755); err != nil {
return err
}
for p, c := range cards {
data, err := drawCard(st, p.Title, p.Summary, s.Config.Name)
if err != nil {
return fmt.Errorf("card for %s: %w", p.Path, err)
}
if err := os.WriteFile(filepath.Join(out, filepath.FromSlash(strings.TrimPrefix(c, "/"))), data, 0o644); err != nil {
return err
}
}
return nil
}