All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m50s
Add support for generating and rendering rich Open Graph (OG) image cards for archive files. When an archive file is shared, the handler now fetches or generates its listing metadata and renders a custom card displaying file/folder counts, uncompressed size, and a visual representation of the archive's contents.
579 lines
18 KiB
Go
579 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
_ "image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/basicfont"
|
|
"golang.org/x/image/font/opentype"
|
|
"golang.org/x/image/math/fixed"
|
|
xdraw "golang.org/x/image/draw"
|
|
_ "golang.org/x/image/webp"
|
|
|
|
"warpbox.dev/backend/libs/helpers"
|
|
"warpbox.dev/backend/libs/jobs"
|
|
"warpbox.dev/backend/libs/services"
|
|
)
|
|
|
|
// Open Graph image dimensions recommended for large summary cards
|
|
// (Discord, Twitter/X, Slack, etc.).
|
|
const (
|
|
ogImageWidth = 1200
|
|
ogImageHeight = 630
|
|
ogMaxTiles = 4
|
|
ogTileGap = 8
|
|
)
|
|
|
|
var ogBackground = color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff}
|
|
|
|
// BoxOGImage renders the social-preview image for a box: a collage of up to
|
|
// four file thumbnails, or a branded placeholder when none are available yet.
|
|
func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := a.uploadService.CanDownload(box); err != nil {
|
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
|
return
|
|
}
|
|
|
|
// Never leak thumbnails of a locked, obfuscated box. (Protected-but-not-
|
|
// obfuscated boxes already show their thumbnails on the download page, so
|
|
// they may appear here too.)
|
|
hideContents := a.uploadService.IsProtected(box) && box.Obfuscate
|
|
|
|
thumbs := make([]image.Image, 0, ogMaxTiles)
|
|
if !hideContents {
|
|
for _, file := range box.Files {
|
|
if len(thumbs) >= ogMaxTiles {
|
|
break
|
|
}
|
|
if file.Thumbnail == "" && file.ThumbnailObjectKey == "" {
|
|
continue
|
|
}
|
|
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
img, _, decodeErr := image.Decode(object.Body)
|
|
object.Body.Close()
|
|
if decodeErr == nil {
|
|
thumbs = append(thumbs, img)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(thumbs) == 0 {
|
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
|
return
|
|
}
|
|
a.serveOGImage(w, r, renderCollage(thumbs))
|
|
}
|
|
|
|
// FileOGImage renders a branded card for files that should not be served as raw
|
|
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
|
|
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
|
box, file, ok := a.loadFileForRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
|
return
|
|
}
|
|
|
|
if jobs.NeedsArchiveListing(file) {
|
|
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
|
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
|
return
|
|
}
|
|
}
|
|
|
|
icon := a.ogFileIcon(file)
|
|
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
|
}
|
|
|
|
type ogArchiveListing struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
FileCount int `json:"fileCount"`
|
|
FolderCount int `json:"folderCount"`
|
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
|
Root *ogArchiveNode `json:"root"`
|
|
}
|
|
|
|
type ogArchiveNode struct {
|
|
Name string `json:"name"`
|
|
Size uint64 `json:"size,omitempty"`
|
|
Dir bool `json:"dir"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Items []*ogArchiveNode `json:"items,omitempty"`
|
|
}
|
|
|
|
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
|
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
|
file.ArchiveListing = listing
|
|
file.ArchiveListingObjectKey = ""
|
|
}
|
|
}
|
|
|
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
|
if err != nil {
|
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
|
file.ArchiveListing = listing
|
|
file.ArchiveListingObjectKey = ""
|
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return ogArchiveListing{}, false
|
|
}
|
|
defer object.Body.Close()
|
|
|
|
var listing ogArchiveListing
|
|
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
|
|
return ogArchiveListing{}, false
|
|
}
|
|
if listing.Root == nil {
|
|
return ogArchiveListing{}, false
|
|
}
|
|
return listing, true
|
|
}
|
|
|
|
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
http.Error(w, "could not render preview image", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
// Social scrapers fetch this rarely and cache on their side; a modest cache
|
|
// keeps it fresh as thumbnails finish generating.
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
http.ServeContent(w, r, "og-image.jpg", time.Time{}, bytes.NewReader(buf.Bytes()))
|
|
}
|
|
|
|
// ogPlaceholder builds the branded fallback image: the file placeholder icon
|
|
// centered on the brand background.
|
|
func (a *App) ogPlaceholder() image.Image {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
|
|
|
file, err := os.Open(filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
|
if err != nil {
|
|
return canvas
|
|
}
|
|
defer file.Close()
|
|
icon, _, err := image.Decode(file)
|
|
if err != nil {
|
|
return canvas
|
|
}
|
|
|
|
// Scale the icon to ~40% of the canvas height and centre it.
|
|
target := ogImageHeight * 2 / 5
|
|
b := icon.Bounds()
|
|
scale := float64(target) / float64(b.Dy())
|
|
dw := int(float64(b.Dx()) * scale)
|
|
dh := target
|
|
x0 := (ogImageWidth - dw) / 2
|
|
y0 := (ogImageHeight - dh) / 2
|
|
xdraw.CatmullRom.Scale(canvas, image.Rect(x0, y0, x0+dw, y0+dh), icon, b, xdraw.Over, nil)
|
|
return canvas
|
|
}
|
|
|
|
func (a *App) ogFileIcon(file services.File) image.Image {
|
|
if a.fileIcons == nil {
|
|
return nil
|
|
}
|
|
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
|
if icon.Retro == "" {
|
|
return nil
|
|
}
|
|
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return img
|
|
}
|
|
|
|
func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
|
|
|
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
|
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
|
|
|
titleFace := a.ogFont(44, true)
|
|
bodyFace := a.ogFont(28, false)
|
|
metaFace := a.ogFont(24, false)
|
|
buttonFace := a.ogFont(26, true)
|
|
|
|
if icon != nil {
|
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
|
|
} else {
|
|
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
|
}
|
|
|
|
titleLines := wrapOGText(file.Name, titleFace, 850)
|
|
if len(titleLines) > 2 {
|
|
titleLines = titleLines[:2]
|
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
|
|
}
|
|
y := 156
|
|
for _, line := range titleLines {
|
|
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
|
y += 52
|
|
}
|
|
|
|
size := helpers.FormatBytes(file.Size)
|
|
typeLabel := strings.TrimSpace(file.ContentType)
|
|
if typeLabel == "" {
|
|
typeLabel = "application/octet-stream"
|
|
}
|
|
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
|
|
|
info := fileCardInfo(file)
|
|
for i, line := range wrapOGText(info, metaFace, 900) {
|
|
if i >= 2 {
|
|
break
|
|
}
|
|
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
|
|
}
|
|
|
|
button := image.Rect(110, 474, 430, 548)
|
|
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
|
|
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
|
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
|
return canvas
|
|
}
|
|
|
|
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
|
|
|
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
|
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
|
|
|
titleFace := a.ogFont(36, true)
|
|
bodyFace := a.ogFont(22, false)
|
|
treeFace := a.ogFont(19, false)
|
|
labelFace := a.ogFont(17, true)
|
|
|
|
icon := a.ogFileIcon(file)
|
|
if icon != nil {
|
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
|
|
} else {
|
|
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
|
}
|
|
|
|
title := listing.Name
|
|
if strings.TrimSpace(title) == "" {
|
|
title = file.Name
|
|
}
|
|
titleLines := wrapOGText(title, titleFace, 820)
|
|
if len(titleLines) > 2 {
|
|
titleLines = titleLines[:2]
|
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
|
|
}
|
|
y := 106
|
|
for _, line := range titleLines {
|
|
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
|
y += 42
|
|
}
|
|
|
|
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
|
|
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
|
|
|
treePanel := image.Rect(104, 214, 1096, 548)
|
|
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
|
|
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
|
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
|
|
|
rows := archiveOGRows(listing.Root, 13)
|
|
rowY := treePanel.Min.Y + 64
|
|
for _, row := range rows {
|
|
if row.Ellipsis {
|
|
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
|
break
|
|
}
|
|
x := treePanel.Min.X + 20 + row.Depth*28
|
|
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
|
|
name := row.Name
|
|
if row.Dir {
|
|
name += "/"
|
|
}
|
|
maxNameWidth := treePanel.Max.X - x - 170
|
|
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
|
|
if !row.Dir {
|
|
size := formatOGArchiveBytes(row.Size)
|
|
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
|
}
|
|
rowY += 23
|
|
}
|
|
|
|
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
|
return canvas
|
|
}
|
|
|
|
type archiveOGRow struct {
|
|
Name string
|
|
Icon string
|
|
Size uint64
|
|
Dir bool
|
|
Depth int
|
|
Ellipsis bool
|
|
}
|
|
|
|
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
|
|
rows := make([]archiveOGRow, 0, limit+1)
|
|
truncated := false
|
|
var walk func(items []*ogArchiveNode, depth int)
|
|
walk = func(items []*ogArchiveNode, depth int) {
|
|
for _, item := range items {
|
|
if len(rows) >= limit {
|
|
truncated = true
|
|
return
|
|
}
|
|
icon := item.Icon
|
|
if item.Dir {
|
|
icon = "folder"
|
|
}
|
|
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
|
|
if item.Dir {
|
|
walk(item.Items, depth+1)
|
|
}
|
|
}
|
|
}
|
|
if root != nil {
|
|
walk(root.Items, 0)
|
|
}
|
|
if truncated {
|
|
rows = append(rows, archiveOGRow{Ellipsis: true})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
|
|
c := archiveOGIconColor(icon)
|
|
rect := image.Rect(x, y, x+20, y+20)
|
|
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
|
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
|
|
if icon == "folder" {
|
|
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
|
|
}
|
|
}
|
|
|
|
func archiveOGIconColor(icon string) color.RGBA {
|
|
switch icon {
|
|
case "folder":
|
|
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
|
|
case "img":
|
|
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
|
|
case "vid":
|
|
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
|
|
case "aud":
|
|
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
|
|
case "code":
|
|
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
|
|
case "arc":
|
|
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
|
|
default:
|
|
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
|
|
}
|
|
}
|
|
|
|
func archiveOGTextColor(row archiveOGRow) color.RGBA {
|
|
if row.Dir {
|
|
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
|
|
}
|
|
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
|
|
}
|
|
|
|
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
|
|
if strings.TrimSpace(listing.Type) != "" {
|
|
return listing.Type
|
|
}
|
|
if strings.TrimSpace(file.ContentType) != "" {
|
|
return file.ContentType
|
|
}
|
|
return "Archive"
|
|
}
|
|
|
|
func formatOGArchiveBytes(size uint64) string {
|
|
const unit = 1024
|
|
if size < unit {
|
|
return fmt.Sprintf("%d B", size)
|
|
}
|
|
value := float64(size) / unit
|
|
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
|
if value < unit {
|
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
|
}
|
|
value /= unit
|
|
}
|
|
return fmt.Sprintf("%.1f PiB", value)
|
|
}
|
|
|
|
func fileCardInfo(file services.File) string {
|
|
switch {
|
|
case strings.HasPrefix(file.ContentType, "audio/"):
|
|
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
|
|
case file.ContentType == "text/markdown":
|
|
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
|
|
case strings.Contains(file.ContentType, "html"):
|
|
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
|
|
case strings.Contains(file.ContentType, "pdf"):
|
|
return "PDF file shared through Warpbox. Open the link to download the original file."
|
|
case strings.HasPrefix(file.ContentType, "text/"):
|
|
return "Text file shared through Warpbox. Open the link to preview the content or download."
|
|
default:
|
|
return "File shared through Warpbox. Open the link to preview available details or download the original."
|
|
}
|
|
}
|
|
|
|
func (a *App) ogFont(size float64, bold bool) font.Face {
|
|
name := "PixeloidSans.ttf"
|
|
if bold {
|
|
name = "PixeloidSans-Bold.ttf"
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
|
|
if err != nil {
|
|
return basicfont.Face7x13
|
|
}
|
|
parsed, err := opentype.Parse(data)
|
|
if err != nil {
|
|
return basicfont.Face7x13
|
|
}
|
|
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
|
if err != nil {
|
|
return basicfont.Face7x13
|
|
}
|
|
return face
|
|
}
|
|
|
|
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
|
d := font.Drawer{
|
|
Dst: dst,
|
|
Src: image.NewUniform(c),
|
|
Face: face,
|
|
Dot: fixed.P(x, y),
|
|
}
|
|
d.DrawString(text)
|
|
}
|
|
|
|
func wrapOGText(text string, face font.Face, maxWidth int) []string {
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
return []string{text}
|
|
}
|
|
lines := []string{}
|
|
current := words[0]
|
|
for _, word := range words[1:] {
|
|
next := current + " " + word
|
|
if ogTextWidth(face, next) <= maxWidth {
|
|
current = next
|
|
continue
|
|
}
|
|
lines = append(lines, current)
|
|
current = word
|
|
}
|
|
lines = append(lines, current)
|
|
return lines
|
|
}
|
|
|
|
func trimOGText(text string, face font.Face, maxWidth int) string {
|
|
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
|
|
text = text[:len(text)-1]
|
|
}
|
|
return strings.TrimSpace(text) + "..."
|
|
}
|
|
|
|
func ogTextWidth(face font.Face, text string) int {
|
|
bounds, _ := font.BoundString(face, text)
|
|
return (bounds.Max.X - bounds.Min.X).Ceil()
|
|
}
|
|
|
|
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
|
func renderCollage(thumbs []image.Image) image.Image {
|
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
|
|
|
cols, rows := collageGrid(len(thumbs))
|
|
cellW := (ogImageWidth - ogTileGap*(cols+1)) / cols
|
|
cellH := (ogImageHeight - ogTileGap*(rows+1)) / rows
|
|
|
|
i := 0
|
|
for ry := 0; ry < rows && i < len(thumbs); ry++ {
|
|
for cx := 0; cx < cols && i < len(thumbs); cx++ {
|
|
x0 := ogTileGap + cx*(cellW+ogTileGap)
|
|
y0 := ogTileGap + ry*(cellH+ogTileGap)
|
|
drawCover(canvas, image.Rect(x0, y0, x0+cellW, y0+cellH), thumbs[i])
|
|
i++
|
|
}
|
|
}
|
|
return canvas
|
|
}
|
|
|
|
func collageGrid(n int) (cols, rows int) {
|
|
switch {
|
|
case n <= 1:
|
|
return 1, 1
|
|
case n == 2:
|
|
return 2, 1
|
|
case n == 3:
|
|
return 3, 1
|
|
default:
|
|
return 2, 2
|
|
}
|
|
}
|
|
|
|
// drawCover scales src to completely fill dst, cropping the overflow (centred),
|
|
// preserving aspect ratio — the CSS object-fit: cover equivalent.
|
|
func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) {
|
|
b := src.Bounds()
|
|
iw, ih := b.Dx(), b.Dy()
|
|
if iw <= 0 || ih <= 0 {
|
|
return
|
|
}
|
|
cellAR := float64(cell.Dx()) / float64(cell.Dy())
|
|
imgAR := float64(iw) / float64(ih)
|
|
|
|
var sw, sh int
|
|
if imgAR > cellAR {
|
|
// Source is wider than the cell: crop the sides.
|
|
sh = ih
|
|
sw = int(float64(ih) * cellAR)
|
|
} else {
|
|
// Source is taller: crop top/bottom.
|
|
sw = iw
|
|
sh = int(float64(iw) / cellAR)
|
|
}
|
|
sx := b.Min.X + (iw-sw)/2
|
|
sy := b.Min.Y + (ih-sh)/2
|
|
xdraw.CatmullRom.Scale(dst, cell, src, image.Rect(sx, sy, sx+sw, sy+sh), xdraw.Over, nil)
|
|
}
|