mandala/banner.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