328 lines
7.6 KiB
Go
328 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/log"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
const maxPositioningAttempts = 150
|
|
|
|
type TextData struct {
|
|
Text []string
|
|
Size float64
|
|
Positioned bool
|
|
X int // left edge of bounding box
|
|
Y int // top edge of bounding box
|
|
W int // width of bounding box
|
|
H int // height of bounding box
|
|
}
|
|
|
|
func NewTextData(text string, size float64, multiline bool) *TextData {
|
|
var splits []string
|
|
if multiline {
|
|
cutPos := int(len(text) / 2)
|
|
splits = []string{text[:cutPos], text[cutPos:]}
|
|
} else {
|
|
splits = []string{text}
|
|
}
|
|
|
|
return &TextData{splits, size, false, -1, -1, -1, -1}
|
|
}
|
|
|
|
// Consistent outline thickness based on font size
|
|
func getOutlineThickness(size float64) int {
|
|
thickness := int(size / 8)
|
|
if thickness < 1 {
|
|
thickness = 1
|
|
} else if thickness > 3 {
|
|
thickness = 3
|
|
}
|
|
return thickness
|
|
}
|
|
|
|
// Actual bounding box for text including outline
|
|
func getTextBounds(text []string, size float64) (width, height int) {
|
|
face, err := createFontFace(size)
|
|
if err != nil {
|
|
// fallback
|
|
charWidth := int(size * 0.6)
|
|
lineHeight := int(size * 1.3)
|
|
maxWidth := 0
|
|
for _, line := range text {
|
|
w := len(line) * charWidth
|
|
if w > maxWidth {
|
|
maxWidth = w
|
|
}
|
|
}
|
|
return maxWidth, len(text) * lineHeight
|
|
}
|
|
|
|
// dimensions for multiline text
|
|
drawer := &font.Drawer{Face: face}
|
|
metrics := face.Metrics()
|
|
lineHeight := int(metrics.Height >> 6)
|
|
|
|
maxWidth := 0
|
|
for _, line := range text {
|
|
advance := drawer.MeasureString(line)
|
|
w := int(advance >> 6)
|
|
if w > maxWidth {
|
|
maxWidth = w
|
|
}
|
|
}
|
|
|
|
// add outline padding
|
|
outlineThickness := getOutlineThickness(size)
|
|
padding := outlineThickness * 2
|
|
|
|
// add line spacing
|
|
totalHeight := len(text)*lineHeight + (len(text)-1)*int(size*0.3)
|
|
|
|
return maxWidth + padding, totalHeight + padding
|
|
}
|
|
|
|
func rectsOverlap(r1, r2 struct{ x, y, w, h int }) bool {
|
|
return !(r1.x+r1.w <= r2.x || r2.x+r2.w <= r1.x ||
|
|
r1.y+r1.h <= r2.y || r2.y+r2.h <= r1.y)
|
|
}
|
|
|
|
func drawTextWithOutline(canvas *image.RGBA, text []string, x, y int, textColor, outlineColor color.RGBA, size float64) {
|
|
face, err := createFontFace(size)
|
|
if err != nil {
|
|
log.Fatalf("Error creating font face: %s", err)
|
|
return
|
|
}
|
|
|
|
outlineThickness := getOutlineThickness(size)
|
|
metrics := face.Metrics()
|
|
ascent := int(metrics.Ascent >> 6)
|
|
lineHeight := int(metrics.Height >> 6)
|
|
|
|
for i, line := range text {
|
|
// baseline position for this line
|
|
baselineY := y + ascent + i*(lineHeight+int(size*0.3)) + outlineThickness
|
|
baselineX := x + outlineThickness
|
|
|
|
// draw outline
|
|
for dy := -outlineThickness; dy <= outlineThickness; dy++ {
|
|
for dx := -outlineThickness; dx <= outlineThickness; dx++ {
|
|
if dx != 0 || dy != 0 {
|
|
d := &font.Drawer{
|
|
Dst: canvas,
|
|
Src: image.NewUniform(outlineColor),
|
|
Face: face,
|
|
Dot: fixed.Point26_6{
|
|
X: fixed.I(baselineX + dx),
|
|
Y: fixed.I(baselineY + dy),
|
|
},
|
|
}
|
|
d.DrawString(line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// draw main text
|
|
d := &font.Drawer{
|
|
Dst: canvas,
|
|
Src: image.NewUniform(textColor),
|
|
Face: face,
|
|
Dot: fixed.Point26_6{
|
|
X: fixed.I(baselineX),
|
|
Y: fixed.I(baselineY),
|
|
},
|
|
}
|
|
d.DrawString(line)
|
|
}
|
|
}
|
|
|
|
// Place all elements to canvas without overlaps
|
|
func findTextPositions(textData []*TextData) {
|
|
padding := 10
|
|
|
|
for i, data := range textData {
|
|
// get actual bounding box dimensions
|
|
w, h := getTextBounds(data.Text, data.Size)
|
|
data.W = w
|
|
data.H = h
|
|
|
|
placed := false
|
|
|
|
// safe bounds for positioning
|
|
minX := padding
|
|
maxX := config.BannerWidth - w - padding
|
|
minY := padding
|
|
maxY := config.BannerHeight - h - padding
|
|
|
|
// check if text can fit
|
|
if maxX < minX || maxY < minY {
|
|
log.Warnf("Text '%v' is too large for canvas", data.Text)
|
|
|
|
// place at top-left as fallback
|
|
data.X = minX
|
|
data.Y = minY
|
|
data.Positioned = true
|
|
continue
|
|
}
|
|
|
|
// try random positions
|
|
for attempt := 0; attempt < maxPositioningAttempts && !placed; attempt++ {
|
|
// gen. random pos.
|
|
x := rand.IntN(maxX-minX+1) + minX
|
|
y := rand.IntN(maxY-minY+1) + minY
|
|
|
|
// bounding box for cur. text
|
|
currentRect := struct{ x, y, w, h int }{
|
|
x: x,
|
|
y: y,
|
|
w: w,
|
|
h: h,
|
|
}
|
|
|
|
// checks of overlaps with already placed texts
|
|
overlaps := false
|
|
for j := 0; j < i; j++ {
|
|
other := textData[j]
|
|
if !other.Positioned {
|
|
continue
|
|
}
|
|
|
|
otherRect := struct{ x, y, w, h int }{
|
|
x: other.X - padding/2,
|
|
y: other.Y - padding/2,
|
|
w: other.W + padding,
|
|
h: other.H + padding,
|
|
}
|
|
|
|
if rectsOverlap(currentRect, otherRect) {
|
|
overlaps = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !overlaps {
|
|
data.X = x
|
|
data.Y = y
|
|
data.Positioned = true
|
|
placed = true
|
|
log.Debugf("Placed text '%v' at (%d, %d)", data.Text, x, y)
|
|
}
|
|
}
|
|
|
|
// fallback: use grid positioning
|
|
if !placed {
|
|
log.Warnf("Using fallback position for text '%v'", data.Text)
|
|
|
|
gridCols := 2
|
|
col := i % gridCols
|
|
row := i / gridCols
|
|
|
|
cellWidth := config.BannerWidth / gridCols
|
|
cellHeight := config.BannerHeight / 3 // NOTE: assuming max 3 rows
|
|
|
|
data.X = col*cellWidth + (cellWidth-w)/2
|
|
data.Y = row*cellHeight + (cellHeight-h)/2
|
|
|
|
// ensure within bounds
|
|
if data.X < padding {
|
|
data.X = padding
|
|
}
|
|
if data.Y < padding {
|
|
data.Y = padding
|
|
}
|
|
if data.X+w > config.BannerWidth-padding {
|
|
data.X = config.BannerWidth - w - padding
|
|
}
|
|
if data.Y+h > config.BannerHeight-padding {
|
|
data.Y = config.BannerHeight - h - padding
|
|
}
|
|
|
|
data.Positioned = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func genPerlinBG() *image.RGBA {
|
|
palette, theme := getRandomThemePalette()
|
|
img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight))
|
|
|
|
// noise params for interesting patterns
|
|
scale1 := 0.01 + rand.Float64()*0.02 // primary pattern scale (adjusted by lib)
|
|
scale2 := 0.03 + rand.Float64()*0.03 // secondary detail
|
|
scale3 := 0.08 + rand.Float64()*0.05 // fine detail
|
|
|
|
// variation offsets
|
|
offsetX := rand.Float64() * 1000
|
|
offsetY := rand.Float64() * 1000
|
|
|
|
log.Infof("Generating background with theme: %s, scales: %.4f, %.4f, %.4f", theme, scale1, scale2, scale3)
|
|
|
|
for y := range config.BannerHeight {
|
|
for x := range config.BannerWidth {
|
|
// multi-octave noise
|
|
fx := (float64(x) + offsetX) * scale1
|
|
fy := (float64(y) + offsetY) * scale1
|
|
|
|
// noise values at different scales
|
|
noise1 := perlinNoise.Noise2D(fx, fy)
|
|
noise2 := perlinNoise.Noise2D(fx/scale1*scale2, fy/scale1*scale2)
|
|
noise3 := perlinNoise.Noise2D(fx/scale1*scale3, fy/scale1*scale3)
|
|
|
|
// combined noise layers with different weights
|
|
combined := noise1*0.6 + noise2*0.3 + noise3*0.1
|
|
|
|
// position-based gradient for a more dynamic look
|
|
gradientX := float64(x) / float64(config.BannerWidth) * 0.2
|
|
gradientY := float64(y) / float64(config.BannerHeight) * 0.1
|
|
|
|
// combined and normalized to [0, 1] range
|
|
noise := (combined + gradientX + gradientY + 1) / 2
|
|
if noise < 0 {
|
|
noise = 0
|
|
}
|
|
if noise > 1 {
|
|
noise = 1
|
|
}
|
|
|
|
pixelColor := palette.interpolate(noise)
|
|
img.Set(x, y, pixelColor)
|
|
}
|
|
}
|
|
|
|
return img
|
|
}
|
|
|
|
func genBanner(textData []*TextData) error {
|
|
canvas := genPerlinBG()
|
|
|
|
findTextPositions(textData)
|
|
|
|
for _, td := range textData {
|
|
if td.Positioned {
|
|
textColor, outlineColor := getRandomTextColors()
|
|
drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size)
|
|
}
|
|
}
|
|
|
|
fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro())
|
|
fp := filepath.Join(bannersDir, fn)
|
|
file, err := os.Create(fp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
return png.Encode(file, canvas)
|
|
}
|
|
|
|
// TODO: functions for banner rotation implementation
|