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