// Package ogimage draws the per-campaign share card. // // The previous stack rendered this with Satori, which turns JSX into a PNG. // Nothing equivalent exists in Go, so the card is drawn directly: filled // rectangles and text on a 1200x630 canvas. The layout is deliberately plain // — this image is seen at thumbnail size in a feed, where a wrong number // matters and a missing rounded corner does not. package ogimage import ( "bytes" _ "embed" "image" "image/color" "image/draw" "image/png" "strings" "sync" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" ) //go:embed Poppins-SemiBold.ttf var poppinsTTF []byte const ( Width = 1200 Height = 630 padding = 64 ) var ( electric = color.RGBA{0x73, 0x60, 0xF9, 0xFF} cream = color.RGBA{0xFF, 0xF1, 0xEB, 0xFF} lime = color.RGBA{0xB9, 0xD4, 0x40, 0xFF} lav = color.RGBA{0xBC, 0x9B, 0xF3, 0xFF} laneBed = color.RGBA{0x2C, 0x23, 0x47, 0xFF} ) // Faces are expensive to build and safe to share, so each size is created once. var ( faceMu sync.Mutex faceCache = map[float64]font.Face{} parsed *opentype.Font parseOnce sync.Once parseErr error ) func face(size float64) (font.Face, error) { parseOnce.Do(func() { parsed, parseErr = opentype.Parse(poppinsTTF) }) if parseErr != nil { return nil, parseErr } faceMu.Lock() defer faceMu.Unlock() if f, ok := faceCache[size]; ok { return f, nil } // DPI 72 makes one point equal one pixel, so sizes read as pixels. f, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull}) if err != nil { return nil, err } faceCache[size] = f return f, nil } // Card is everything the image shows. Formatting happens in the caller so this // package never has to know about Hungarian number rules. type Card struct { Title string Category string Creator string Raised string GoalNote string Percent string // Fraction is the share of the bar that is filled, 0..1, and Over is the // part beyond the goal, drawn in lime. Both are already scaled. Fraction float64 Over float64 Backers string Deadline string } // Render draws the card and returns a PNG. func Render(c Card) ([]byte, error) { img := image.NewRGBA(image.Rect(0, 0, Width, Height)) fill(img, img.Bounds(), electric) // Header: mark, wordmark, category. fill(img, image.Rect(padding, padding, padding+56, padding+56), cream) x, err := text(img, "Szabadpálya", padding+76, padding+38, 30, cream) if err != nil { return nil, err } if c.Category != "" { if _, err := text(img, c.Category, x+24, padding+38, 22, lime); err != nil { return nil, err } } // Title, wrapped to at most three lines so a long one cannot run into the // progress bar. titleFace, err := face(68) if err != nil { return nil, err } lines := wrap(c.Title, titleFace, Width-2*padding, 3) y := 246 for _, line := range lines { if _, err := text(img, line, padding, y, 68, cream); err != nil { return nil, err } y += 76 } if c.Creator != "" { if _, err := text(img, c.Creator, padding, y+18, 28, lav); err != nil { return nil, err } } // Progress bar. The filled part is cream and anything past 100% is lime, // matching the funding lane on the campaign page. barTop, barBottom := 430, 456 barWidth := Width - 2*padding fill(img, image.Rect(padding, barTop, padding+barWidth, barBottom), laneBed) filled := int(float64(barWidth) * clamp(c.Fraction)) fill(img, image.Rect(padding, barTop, padding+filled, barBottom), cream) if c.Over > 0 { over := int(float64(barWidth) * clamp(c.Over)) fill(img, image.Rect(padding+filled, barTop, padding+filled+over, barBottom), lime) } // Numbers. Laid out left to right with measured widths so nothing overlaps // when a figure grows a digit. x = padding if x, err = text(img, c.Raised, x, 522, 52, cream); err != nil { return nil, err } if _, err := text(img, c.GoalNote, padding, 558, 22, lav); err != nil { return nil, err } x += 44 if x, err = text(img, c.Percent, x, 522, 52, lime); err != nil { return nil, err } x += 44 if x, err = text(img, c.Backers, x, 522, 26, cream); err != nil { return nil, err } x += 36 if _, err = text(img, c.Deadline, x, 522, 26, cream); err != nil { return nil, err } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, err } return buf.Bytes(), nil } // Fallback is the card for a slug that does not resolve: the wordmark alone, // so a stale share link still produces something branded rather than a broken // image icon. func Fallback() ([]byte, error) { img := image.NewRGBA(image.Rect(0, 0, Width, Height)) fill(img, img.Bounds(), electric) f, err := face(64) if err != nil { return nil, err } w := font.MeasureString(f, "Szabadpálya").Ceil() if _, err := text(img, "Szabadpálya", (Width-w)/2, Height/2, 64, cream); err != nil { return nil, err } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, err } return buf.Bytes(), nil } func fill(img *image.RGBA, r image.Rectangle, c color.Color) { draw.Draw(img, r.Intersect(img.Bounds()), &image.Uniform{C: c}, image.Point{}, draw.Src) } // text draws a string with its baseline at y and returns the x it ended at, so // the caller can place the next run without guessing a width. func text(img *image.RGBA, s string, x, y int, size float64, c color.Color) (int, error) { f, err := face(size) if err != nil { return x, err } d := &font.Drawer{ Dst: img, Src: &image.Uniform{C: c}, Face: f, Dot: fixed.P(x, y), } d.DrawString(s) return d.Dot.X.Ceil(), nil } // wrap breaks text on word boundaries to fit maxWidth, giving up after // maxLines and ending the last one with an ellipsis. func wrap(s string, f font.Face, maxWidth, maxLines int) []string { words := strings.Fields(s) if len(words) == 0 { return nil } var lines []string current := words[0] for _, w := range words[1:] { candidate := current + " " + w if font.MeasureString(f, candidate).Ceil() <= maxWidth { current = candidate continue } lines = append(lines, current) if len(lines) == maxLines { return truncateLast(lines, f, maxWidth) } current = w } lines = append(lines, current) if len(lines) > maxLines { lines = lines[:maxLines] return truncateLast(lines, f, maxWidth) } return lines } func truncateLast(lines []string, f font.Face, maxWidth int) []string { last := lines[len(lines)-1] for font.MeasureString(f, last+"…").Ceil() > maxWidth && len(last) > 0 { last = strings.TrimRight(last[:len(last)-1], " ") } lines[len(lines)-1] = last + "…" return lines } func clamp(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v }