Text overlay support

This commit is contained in:
2026-04-18 11:38:16 -06:00
parent 44dc22d8ee
commit 962ee747fd
7 changed files with 495 additions and 33 deletions

View File

@@ -3,10 +3,12 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
_ "image/jpeg"
@@ -27,6 +29,11 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"gopkg.in/yaml.v3"
)
@@ -223,6 +230,14 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
// Regular key: load icon once.
if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
continue
}
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
@@ -249,6 +264,9 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue
}
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
@@ -371,6 +389,10 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
return
}
}
if keyCfg.Text != "" {
imgTrue = overlayText(imgTrue, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
imgFalse = overlayText(imgFalse, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
lastState := -1 // unknown, forces initial icon set
var animCancel context.CancelFunc
@@ -555,6 +577,169 @@ func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time
return frames, delays, nil
}
// --- Text overlay ---
var (
parsedFont *opentype.Font
fontOnce sync.Once
)
func getFont() *opentype.Font {
fontOnce.Do(func() {
f, err := opentype.Parse(gobold.TTF)
if err != nil {
log.Fatalf("parse embedded font: %v", err)
}
parsedFont = f
})
return parsedFont
}
// parseTextColor converts a color name or hex string to a color.Color.
// Supported names: white (default), black, red, blue. Hex: "#RRGGBB".
func parseTextColor(s string) color.Color {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "white":
return color.White
case "black":
return color.Black
case "red":
return color.RGBA{R: 255, A: 255}
case "blue":
return color.RGBA{B: 255, A: 255}
default:
s = strings.TrimPrefix(s, "#")
if len(s) == 6 {
b, err := hex.DecodeString(s)
if err == nil && len(b) == 3 {
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
}
}
log.Printf("unknown text_color %q, using white", s)
return color.White
}
}
// contrastColor returns black or white depending on which contrasts better
// with the given color. Used for the text outline/shadow.
func contrastColor(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
// Perceived luminance (values are 16-bit, so divide by 257 to get 8-bit).
lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/257)
if lum > 128 {
return color.Black
}
return color.White
}
// splitLines splits text on explicit newlines only. No automatic word wrapping.
func splitLines(text string) []string {
return strings.Split(text, "\n")
}
// overlayText renders text onto img at keySize resolution with auto-sizing.
// The image is first scaled to keySize×keySize so font sizes are consistent
// regardless of source image dimensions.
// textColor is parsed from the config; shadow/outline uses the contrasting color.
func overlayText(img image.Image, text, textColorStr string, keySize int) image.Image {
if text == "" {
return img
}
// Scale to key resolution so font sizing is consistent.
dst := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
w, h := keySize, keySize
f := getFont()
marginX := int(float64(w) * 0.06)
marginY := int(float64(h) * 0.06)
availW := w - 2*marginX
availH := h - 2*marginY
// Split on explicit newlines only — no automatic word wrapping.
lines := splitLines(text)
// Find the largest font size where all lines fit.
// Cap at h*0.16 (~15pt on 96px key) so text stays label-sized.
var bestFace font.Face
maxSize := float64(h) * 0.16
const minSize = 6.0
for size := maxSize; size >= minSize; size -= 0.5 {
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
continue
}
lineH := face.Metrics().Height.Ceil()
fits := lineH*len(lines) <= availH
if fits {
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > availW {
fits = false
break
}
}
}
if fits {
bestFace = face
break
}
}
if bestFace == nil {
bestFace, _ = opentype.NewFace(f, &opentype.FaceOptions{
Size: minSize, DPI: 72, Hinting: font.HintingFull,
})
}
metrics := bestFace.Metrics()
lineH := metrics.Height.Ceil()
totalH := lineH * len(lines)
// Bottom-align so the icon stays visible above.
startY := h - marginY - totalH + metrics.Ascent.Ceil()
txtColor := parseTextColor(textColorStr)
shadow := contrastColor(txtColor)
// Outline offsets for readability on any background.
offsets := [8]image.Point{
{-1, -1}, {0, -1}, {1, -1},
{-1, 0}, {1, 0},
{-1, 1}, {0, 1}, {1, 1},
}
for i, line := range lines {
adv := font.MeasureString(bestFace, line)
x := (w - adv.Ceil()) / 2
y := startY + i*lineH
// Draw outline.
for _, off := range offsets {
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(shadow),
Face: bestFace,
Dot: fixed.P(x+off.X, y+off.Y),
}
d.DrawString(line)
}
// Draw text.
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(txtColor),
Face: bestFace,
Dot: fixed.P(x, y),
}
d.DrawString(line)
}
return dst
}
func loadImage(path string) (image.Image, error) {
if strings.ToLower(filepath.Ext(path)) == ".svg" {
return loadSVG(path)