- Per-post OG tags (title, description, image, date) in template head - Twitter Card support (summary_large_image) - Auto-generate 1200x630 card images from embedded base PNG - Title, description, domain, date drawn on image via fogleman/gg - Fonts embedded via go:embed (OpenSans Bold + Regular) - Optional og-image: and og-description: front matter overrides - Layout constants in ogimage.go for easy repositioning - Generated images cached in content/og/ (gitignored) 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
168 lines
3.8 KiB
Go
168 lines
3.8 KiB
Go
package blog
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"image"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/fogleman/gg"
|
|
"github.com/golang/freetype/truetype"
|
|
"golang.org/x/image/font"
|
|
)
|
|
|
|
//go:embed assets/OpenSans-Bold.ttf
|
|
var fontBoldBytes []byte
|
|
|
|
//go:embed assets/OpenSans-Regular.ttf
|
|
var fontRegularBytes []byte
|
|
|
|
//go:embed assets/og-base.png
|
|
var ogBaseImage []byte
|
|
|
|
var (
|
|
fontBoldParsed *truetype.Font
|
|
fontRegularParsed *truetype.Font
|
|
)
|
|
|
|
func init() {
|
|
fontBoldParsed, _ = truetype.Parse(fontBoldBytes)
|
|
fontRegularParsed, _ = truetype.Parse(fontRegularBytes)
|
|
}
|
|
|
|
func makeFace(f *truetype.Font, size float64) font.Face {
|
|
return truetype.NewFace(f, &truetype.Options{
|
|
Size: size,
|
|
DPI: 72,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
}
|
|
|
|
// Layout constants — adjust these to reposition text
|
|
const (
|
|
ogW = 1200
|
|
ogH = 630
|
|
|
|
// Text section (left side)
|
|
textX = 60 // left padding for title/description
|
|
textW = 680 // max text width for wrapping
|
|
textStartY = 195 // top of title block
|
|
lineGap = 12 // gap between title and description
|
|
|
|
// Art section (right side) — domain and date centered here
|
|
artStartX = 770 // left edge of art section
|
|
artCX = 985 // horizontal center of art section (770 + 430/2)
|
|
domainX = 1080
|
|
dateX = 1100
|
|
domainY = 590 // domain text Y
|
|
dateY = 30 // date text Y
|
|
|
|
titleFontSize = 56
|
|
descFontSize = 26
|
|
domainFontSize = 22
|
|
maxTitleLines = 3
|
|
maxDescLines = 5
|
|
)
|
|
|
|
func ensureGeneratedOGImage(dir, slug, title, dateStr, description string) string {
|
|
outPath := filepath.Join(dir, slug+".png")
|
|
|
|
if _, err := os.Stat(outPath); err == nil {
|
|
return outPath
|
|
}
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return ""
|
|
}
|
|
|
|
img := generateOGImage(title, dateStr, description)
|
|
|
|
f, err := os.Create(outPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
png.Encode(f, img)
|
|
|
|
return outPath
|
|
}
|
|
|
|
func generateOGImage(title, dateStr, description string) image.Image {
|
|
// Decode the embedded base image
|
|
baseImg, _, _ := image.Decode(bytes.NewReader(ogBaseImage))
|
|
dc := gg.NewContext(ogW, ogH)
|
|
dc.DrawImage(baseImg, 0, 0)
|
|
|
|
// Domain name — centered in right art section, top
|
|
dc.SetFontFace(makeFace(fontRegularParsed, domainFontSize))
|
|
dc.SetRGBA(0.663, 0.639, 0.769, 0.8)
|
|
dc.DrawStringAnchored("chaosmith.systems", float64(domainX), float64(domainY), 0.5, 0.9)
|
|
|
|
// Date — centered in right art section, bottom
|
|
if dateStr != "" {
|
|
dc.SetRGBA(0.663, 0.639, 0.769, 0.6)
|
|
dc.DrawStringAnchored(dateStr, float64(dateX), float64(dateY), 0.5, 0.9)
|
|
}
|
|
|
|
// Title — right section, top-aligned
|
|
dc.SetFontFace(makeFace(fontBoldParsed, titleFontSize))
|
|
dc.SetRGB(0.953, 0.941, 1.0)
|
|
|
|
titleLines := wrapText(dc, title, float64(textW), maxTitleLines)
|
|
currY := float64(textStartY)
|
|
lineH := titleFontSize * 1.2
|
|
for _, line := range titleLines {
|
|
dc.DrawStringAnchored(line, float64(textX), currY, 0, 0.5)
|
|
currY += lineH
|
|
}
|
|
|
|
// Description — below title
|
|
currY += float64(lineGap)
|
|
dc.SetFontFace(makeFace(fontRegularParsed, descFontSize))
|
|
dc.SetRGBA(0.663, 0.639, 0.769, 0.8)
|
|
|
|
descLines := wrapText(dc, description, float64(textW), maxDescLines)
|
|
descLineH := descFontSize * 1.3
|
|
for _, line := range descLines {
|
|
dc.DrawStringAnchored(line, float64(textX), currY, 0, 0.5)
|
|
currY += descLineH
|
|
}
|
|
|
|
return dc.Image()
|
|
}
|
|
|
|
func wrapText(dc *gg.Context, text string, maxW float64, maxLines int) []string {
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
return []string{""}
|
|
}
|
|
|
|
var lines []string
|
|
curr := words[0]
|
|
for _, w := range words[1:] {
|
|
test := curr + " " + w
|
|
width, _ := dc.MeasureString(test)
|
|
if width > maxW {
|
|
lines = append(lines, curr)
|
|
curr = w
|
|
} else {
|
|
curr = test
|
|
}
|
|
}
|
|
lines = append(lines, curr)
|
|
|
|
if len(lines) > maxLines {
|
|
lines = lines[:maxLines]
|
|
last := lines[maxLines-1]
|
|
if len(last) > 3 {
|
|
last = last[:len(last)-3] + "..."
|
|
}
|
|
lines[maxLines-1] = last
|
|
}
|
|
|
|
return lines
|
|
}
|