Text overlay support
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
|
||||
// 4. Walks the user through a series of huh forms:
|
||||
// - Pick a key slot (0-31)
|
||||
// - Choose action type: module function or raw shell command
|
||||
// - Choose action type: module function, shell command, or toggle key (with poll)
|
||||
// - If module: pick module → function → customize params (with defaults)
|
||||
// - If command: type a shell command
|
||||
// - Pick an icon from icons_dir (or type a custom filename)
|
||||
@@ -22,10 +22,10 @@
|
||||
//
|
||||
// ## Expanding this tool
|
||||
//
|
||||
// To add new TUI steps (e.g. toggle key setup with poll config, or a
|
||||
// "delete key" flow), add a new function following the pattern of
|
||||
// configureModuleKey/configureCommandKey: build huh forms, collect values
|
||||
// into a keyEntry, return it for the confirm/append step.
|
||||
// To add new TUI steps (e.g. a "delete key" flow), add a new function
|
||||
// following the pattern of configureModuleKey/configureCommandKey/
|
||||
// configureToggleKey: build huh forms, collect values into a keyEntry,
|
||||
// return it for the confirm/append step.
|
||||
//
|
||||
// To support new key types in the YAML output, update keyEntry and
|
||||
// renderKeySnippet() in yaml.go.
|
||||
@@ -185,29 +185,51 @@ func runOnce(cfgPath string) error {
|
||||
var entry keyEntry
|
||||
entry.Index = keyIdx
|
||||
|
||||
if actionType == "module" {
|
||||
// Module path: module → function → params (with defaults pre-filled).
|
||||
switch actionType {
|
||||
case "module":
|
||||
entry, err = configureModuleKey(keyIdx, reg)
|
||||
} else {
|
||||
// Command path: just a shell command string.
|
||||
case "toggle":
|
||||
entry, err = configureToggleKey(keyIdx, reg)
|
||||
default:
|
||||
entry, err = configureCommandKey(keyIdx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Pick an icon file.
|
||||
icon, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
// Step 4: Pick icon(s).
|
||||
if entry.IsToggle {
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
|
||||
iconTrue, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.IconTrue = iconTrue
|
||||
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
|
||||
iconFalse, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.IconFalse = iconFalse
|
||||
} else {
|
||||
icon, err := pickIcon(cfg.IconsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.Icon = icon
|
||||
}
|
||||
entry.Icon = icon
|
||||
|
||||
// Step 5: Show summary and confirm before writing.
|
||||
fmt.Println()
|
||||
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
|
||||
fmt.Printf(" Key: %d\n", entry.Index)
|
||||
fmt.Printf(" Icon: %s\n", entry.Icon)
|
||||
if entry.IsToggle {
|
||||
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
|
||||
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
|
||||
} else {
|
||||
fmt.Printf(" Icon: %s\n", entry.Icon)
|
||||
}
|
||||
if entry.Module != "" {
|
||||
fmt.Printf(" Module: %s\n", entry.Module)
|
||||
fmt.Printf(" Function: %s\n", entry.Function)
|
||||
@@ -220,6 +242,27 @@ func runOnce(cfgPath string) error {
|
||||
} else {
|
||||
fmt.Printf(" Command: %s\n", entry.Command)
|
||||
}
|
||||
if entry.IsToggle {
|
||||
fmt.Println(" Poll:")
|
||||
if entry.PollModule != "" {
|
||||
fmt.Printf(" Module: %s\n", entry.PollModule)
|
||||
fmt.Printf(" Function: %s\n", entry.PollFunction)
|
||||
if len(entry.PollParams) > 0 {
|
||||
fmt.Println(" Params:")
|
||||
for k, v := range entry.PollParams {
|
||||
fmt.Printf(" %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Command: %s\n", entry.PollCommand)
|
||||
}
|
||||
if entry.PollMatch != "" {
|
||||
fmt.Printf(" Match: %s\n", entry.PollMatch)
|
||||
}
|
||||
if entry.PollInterval != "" {
|
||||
fmt.Printf(" Interval: %s\n", entry.PollInterval)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
var confirm bool
|
||||
@@ -281,10 +324,8 @@ func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
|
||||
return keyIdx, nil
|
||||
}
|
||||
|
||||
// pickActionType asks whether to configure a module function or a raw shell command.
|
||||
//
|
||||
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
|
||||
// through icon_true/icon_false and poll config setup.
|
||||
// pickActionType asks whether to configure a module function, a raw shell command,
|
||||
// or a toggle key with poll-based state checking.
|
||||
func pickActionType() (string, error) {
|
||||
var actionType string
|
||||
form := huh.NewForm(
|
||||
@@ -292,8 +333,9 @@ func pickActionType() (string, error) {
|
||||
huh.NewSelect[string]().
|
||||
Title("What should this key do?").
|
||||
Options(
|
||||
huh.NewOption("Module function (Slack, etc.)", "module"),
|
||||
huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
huh.NewOption("Toggle key (with poll)", "toggle"),
|
||||
).
|
||||
Value(&actionType),
|
||||
),
|
||||
@@ -434,6 +476,123 @@ func configureCommandKey(keyIdx int) (keyEntry, error) {
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// configureToggleKey walks the user through setting up a toggle key:
|
||||
// press action (module or command) + poll configuration (module or command,
|
||||
// match string, interval). Icons are handled separately in runOnce().
|
||||
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
|
||||
entry := keyEntry{Index: keyIdx, IsToggle: true}
|
||||
|
||||
// Ask what happens when the key is pressed.
|
||||
var pressType string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What should pressing this key do?").
|
||||
Options(
|
||||
huh.NewOption("Module function", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
).
|
||||
Value(&pressType),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return entry, err
|
||||
}
|
||||
|
||||
if pressType == "module" {
|
||||
modEntry, err := configureModuleKey(keyIdx, reg)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Module = modEntry.Module
|
||||
entry.Function = modEntry.Function
|
||||
entry.Params = modEntry.Params
|
||||
} else {
|
||||
cmdEntry, err := configureCommandKey(keyIdx)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Command = cmdEntry.Command
|
||||
}
|
||||
|
||||
// Configure the poll block.
|
||||
if err := configurePoll(&entry, reg); err != nil {
|
||||
return entry, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// configurePoll walks through poll configuration for a toggle key: how to check
|
||||
// state (module function or shell command), optional match string, and interval.
|
||||
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
|
||||
var pollType string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("How should the key check its state?").
|
||||
Options(
|
||||
huh.NewOption("Module function", "module"),
|
||||
huh.NewOption("Shell command", "command"),
|
||||
).
|
||||
Value(&pollType),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pollType == "module" {
|
||||
modEntry, err := configureModuleKey(entry.Index, reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollModule = modEntry.Module
|
||||
entry.PollFunction = modEntry.Function
|
||||
entry.PollParams = modEntry.Params
|
||||
} else {
|
||||
var cmd string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Poll command").
|
||||
Description("Shell command to check key state").
|
||||
Value(&cmd).
|
||||
Placeholder("e.g. pgrep -x myapp"),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollCommand = cmd
|
||||
}
|
||||
|
||||
// Match string and interval.
|
||||
var match, interval string
|
||||
interval = "2s"
|
||||
form = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Match string").
|
||||
Description("Substring in output that means ON (leave empty to use exit code)").
|
||||
Value(&match).
|
||||
Placeholder("optional"),
|
||||
huh.NewInput().
|
||||
Title("Poll interval").
|
||||
Description("How often to check state").
|
||||
Value(&interval).
|
||||
Placeholder("2s"),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.PollMatch = match
|
||||
entry.PollInterval = interval
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickIcon lets the user select an icon file.
|
||||
//
|
||||
// If icons_dir has image files, shows a select list of them plus a "type custom"
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
//
|
||||
// ## Expanding
|
||||
//
|
||||
// To support toggle keys (icon_true/icon_false + poll block), add those fields
|
||||
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines.
|
||||
// Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle
|
||||
// and the Poll* fields on keyEntry, and the toggle branch in renderKeySnippet().
|
||||
//
|
||||
// To support deleting or editing existing keys, you'd need a different strategy
|
||||
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
|
||||
@@ -51,9 +51,11 @@ import (
|
||||
// keyEntry holds the data collected from the TUI forms, ready to be rendered
|
||||
// as a YAML snippet and appended to config.yaml.
|
||||
//
|
||||
// Two mutually exclusive modes:
|
||||
// Three mutually exclusive modes:
|
||||
// - Module key: Module + Function + Params are set, Command is empty
|
||||
// - Command key: Command is set, Module/Function/Params are empty
|
||||
// - Toggle key: IsToggle is true, IconTrue/IconFalse replace Icon,
|
||||
// and Poll* fields define state checking. Press action uses Module or Command.
|
||||
type keyEntry struct {
|
||||
Index int // key slot (0-31)
|
||||
Icon string // icon filename relative to icons_dir
|
||||
@@ -61,6 +63,17 @@ type keyEntry struct {
|
||||
Function string // function name within the module (e.g. "set_status")
|
||||
Params map[string]string // param overrides (merged with module defaults at runtime)
|
||||
Command string // raw shell command (non-module keys only)
|
||||
|
||||
// Toggle key fields
|
||||
IsToggle bool
|
||||
IconTrue string // icon when poll state is true/on
|
||||
IconFalse string // icon when poll state is false/off
|
||||
PollCommand string // shell command to check state
|
||||
PollModule string // module for poll (alternative to PollCommand)
|
||||
PollFunction string // function for poll
|
||||
PollParams map[string]string // poll param overrides
|
||||
PollMatch string // substring match in output -> true state
|
||||
PollInterval string // poll frequency (e.g. "2s")
|
||||
}
|
||||
|
||||
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
|
||||
@@ -123,7 +136,13 @@ func renderKeySnippet(e keyEntry) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, " %d:\n", e.Index)
|
||||
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
|
||||
|
||||
if e.IsToggle {
|
||||
fmt.Fprintf(&b, " icon_true: %s\n", e.IconTrue)
|
||||
fmt.Fprintf(&b, " icon_false: %s\n", e.IconFalse)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
|
||||
}
|
||||
|
||||
if e.Module != "" {
|
||||
fmt.Fprintf(&b, " module: %s\n", e.Module)
|
||||
@@ -144,5 +163,32 @@ func renderKeySnippet(e keyEntry) string {
|
||||
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
|
||||
}
|
||||
|
||||
if e.IsToggle {
|
||||
b.WriteString(" poll:\n")
|
||||
if e.PollModule != "" {
|
||||
fmt.Fprintf(&b, " module: %s\n", e.PollModule)
|
||||
fmt.Fprintf(&b, " function: %s\n", e.PollFunction)
|
||||
if len(e.PollParams) > 0 {
|
||||
b.WriteString(" params:\n")
|
||||
pkeys := make([]string, 0, len(e.PollParams))
|
||||
for k := range e.PollParams {
|
||||
pkeys = append(pkeys, k)
|
||||
}
|
||||
sort.Strings(pkeys)
|
||||
for _, k := range pkeys {
|
||||
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.PollParams[k])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&b, " command: \"%s\"\n", e.PollCommand)
|
||||
}
|
||||
if e.PollMatch != "" {
|
||||
fmt.Fprintf(&b, " match: \"%s\"\n", e.PollMatch)
|
||||
}
|
||||
if e.PollInterval != "" {
|
||||
fmt.Fprintf(&b, " interval: \"%s\"\n", e.PollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user