Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/unix-streamdeck/gg v0.0.0-20260313120600-9d60d38ce9f9
go.uber.org/mock v0.6.0
golang.org/x/image v0.37.0
golang.org/x/image v0.38.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
282 changes: 231 additions & 51 deletions img.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ import (
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/gofont/gosmallcaps"
"golang.org/x/image/font/gofont/gosmallcapsitalic"
"golang.org/x/image/math/fixed"
)

const BorderClearance = 10

// TODO replace use of gg with native font.Drawer
type VerticalAlignment string

const (
Expand All @@ -39,11 +39,30 @@ const (
Bottom VerticalAlignment = "BOTTOM"
)

type HorizontalAlignment string

const (
Left HorizontalAlignment = "LEFT"
Middle HorizontalAlignment = "MIDDLE"
Right HorizontalAlignment = "RIGHT"
)

type Overflow string

const (
Wrap Overflow = "WRAP"
Fade Overflow = "FADE"
)

type DrawTextOptions struct {
FontSize int64
VerticalAlignment VerticalAlignment
FontFace string
Colour string
FontSize int64
VerticalAlignment VerticalAlignment
HorizontalAlignment HorizontalAlignment
FontFace string
Colour string
Overflow Overflow
// With anchor set, the alignments indicate the position of the text the anchor will be in, e.g BOTTOM & RIGHT will put the anchor in the bottom right of the text, so all text will be up and left of it
Anchor *image.Point
}

type IContext interface {
Expand All @@ -59,41 +78,115 @@ type IContext interface {
}

func DrawText(img image.Image, text string, options DrawTextOptions) (image.Image, error) {
ggImg := gg.NewContextForImage(img)
return drawText(ggImg, text, options)
}

func drawText(img IContext, text string, options DrawTextOptions) (image.Image, error) {
width, height := img.Width(), img.Height()
img.SetRGB(1, 1, 1)
if options.Overflow == "" {
options.Overflow = Wrap
}

drawImg, ok := img.(draw.Image)

if !ok {
return img, errors.New("cannot convert")
}

width, height := img.Bounds().Dx(), img.Bounds().Dy()

availableWidth, availableHeight := width, height

if options.Anchor != nil {
availableWidth, availableHeight = width-options.Anchor.X, height-options.Anchor.Y

if options.VerticalAlignment == Center {
availableHeight = height - options.Anchor.Y
}

if options.HorizontalAlignment == Middle {
availableWidth = width - options.Anchor.X
}
}

col := color.RGBA{0xff, 0xff, 0xff, 0xff}

//img.SetRGB(1, 1, 1)
matched, _ := regexp.MatchString(`#?([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})`, options.Colour)
if matched {
img.SetHexColor(options.Colour)
col = HexColor(options.Colour)
}
f, err := truetype.Parse(loadFontFace(options.FontFace))
if err != nil {
return nil, err
}
fSize := calculateFontSize(f, text, img)
fSize := calculateFontSize(f, text, availableWidth, availableHeight, options.Overflow)

if options.FontSize != 0 {
fSize = float64(options.FontSize)
}

face := truetype.NewFace(f, &truetype.Options{Size: fSize})
defer face.Close()
img.SetFontFace(face)

lines := img.WordWrap(text, float64(width-BorderClearance))
lineCount := float64(len(lines))
lines := text

if options.Overflow == Wrap {
lines = wrapString(text, availableWidth, face)
}

lineCount := strings.Count(lines, "\n") + 1

var y float64

var x int

w, _ := getTextBounds(lines, face)

if options.Anchor != nil {
x = calculateHorizonalAlignment(options.HorizontalAlignment, int(w), options.Anchor.X, true)
y = calculateVerticalAlignment(options.VerticalAlignment, options.Anchor.Y, lineCount, fSize, true)
} else {
x = calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width, false)
y = calculateVerticalAlignment(options.VerticalAlignment, height, lineCount, fSize, false)
}

d := &font.Drawer{
Dst: drawImg,
Src: image.NewUniform(col),
Face: face,
}

d.Dot = fixed.Point26_6{
X: fixed.I(x),
Y: fixed.I(int(y) + (int(fSize) / 4)),
}

linesSplit := strings.Split(lines, "\n")

if strings.Contains(text, "\n") {
lineCount += float64(strings.Count(text, "\n") + 1)
if len(linesSplit) == 1 {
d.DrawString(lines)
return img, nil
}

valign, y := calculateVerticalAlignment(options.VerticalAlignment, height)
img.DrawStringWrapped(text, float64(width/2), y, 0.5, valign, float64(width-BorderClearance), 1, gg.AlignCenter)
return img.Image(), nil
linesAbove := float64(lineCount) / 2

linesAbove = linesAbove - 1

startingLineY := y - (linesAbove * fSize)

for i, line := range linesSplit {
w, _ := getTextBounds(line, face)

if options.Anchor != nil {
x = calculateHorizonalAlignment(options.HorizontalAlignment, int(w), options.Anchor.X, true)
} else {
x = calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width, false)
}

d.Dot = fixed.Point26_6{
X: fixed.I(x),
Y: fixed.I(int(startingLineY + (float64(i) * fSize))),
}
d.DrawString(line)
}
return img, nil
}

// TODO Support loading fonts via fontconfig on linux and whatever the equivalent is on darwin
Expand Down Expand Up @@ -128,29 +221,60 @@ func loadFontFace(fontName string) []byte {
}
}

func calculateVerticalAlignment(alignment VerticalAlignment, height int) (float64, float64) {
func calculateVerticalAlignment(alignment VerticalAlignment, height, lines int, fSize float64, anchor bool) float64 {
textMidPoint := (float64(lines) / 2.0) * fSize
if !anchor {
if alignment == Top {
return (BorderClearance / 2) + (textMidPoint)
}
if alignment == Bottom {
return float64(height) - (BorderClearance / 2) - textMidPoint
}
return float64(height) / 2
}

if alignment == Top {
return 0.0, BorderClearance / 2
return float64(height) + textMidPoint
}
if alignment == Bottom {
return 1.0, float64(height) - (BorderClearance / 2)
return float64(height) - textMidPoint
}
return float64(height)
}

func calculateHorizonalAlignment(alignment HorizontalAlignment, textWidth, width int, anchor bool) int {
if !anchor {
if alignment == Left {
return BorderClearance / 2
}
if alignment == Right {
return width - (BorderClearance / 2) - textWidth
}
return ((width) / 2) - (int(textWidth) / 2)
}
return 0.5, float64(height) / 2

if alignment == Left {
return width
}

if alignment == Right {
return width - textWidth
}

return width - (textWidth / 2)
}

func calculateFontSize(f *truetype.Font, text string, img IContext) float64 {
width := img.Width()
func calculateFontSize(f *truetype.Font, text string, width, height int, overflow Overflow) float64 {
fontSize := float64(width) / 3
face := truetype.NewFace(f, &truetype.Options{Size: fontSize})
defer face.Close()
img.SetFontFace(face)
textWidth, _ := img.MeasureMultilineString(text, 1.0)
w, _ := getTextBounds(text, face)
fSize := fontSize
if textWidth >= float64(width-BorderClearance) {
oversizeRatio := float64(width-BorderClearance) / textWidth
if w >= float64(width-BorderClearance) {
oversizeRatio := float64(width-BorderClearance) / w
scaledFontSize := math.Min(oversizeRatio*fontSize, 12)
for size := fontSize; size >= scaledFontSize; size -= 0.5 {
if attemptFontSize(f, text, img, size) {
if attemptFontSize(f, text, width, height, size, overflow) {
return size
}
}
Expand All @@ -159,16 +283,69 @@ func calculateFontSize(f *truetype.Font, text string, img IContext) float64 {
return fSize
}

func attemptFontSize(f *truetype.Font, text string, img IContext, fSize float64) bool {
width := img.Width()
height := img.Height()
func attemptFontSize(f *truetype.Font, text string, width, height int, fSize float64, overflow Overflow) bool {
face := truetype.NewFace(f, &truetype.Options{Size: fSize})
defer face.Close()
img.SetFontFace(face)
wrappedGroups := img.WordWrap(text, float64(width-BorderClearance))
wrappedText := strings.Join(wrappedGroups, "\n")
textWidth, textHeight := img.MeasureMultilineString(wrappedText, 1.0)
return textHeight < float64(height-BorderClearance) && textWidth < float64(width-BorderClearance)
w, h := getTextBounds(text, face)
if w <= float64(width-BorderClearance) {
return true
}
if h > float64(height) {
return false
}
if overflow != Wrap {
return false
}
lines := wrapString(text, width, face)
if lines == "" {
return false
}
maxTextWidth := 0.0
for _, s := range strings.Split(lines, "\n") {
textWidth, _ := getTextBounds(s, face)
if textWidth > maxTextWidth {
maxTextWidth = textWidth
}
}
textHeight := float64(strings.Count(lines, "\n")+1) * fSize
if textHeight < float64(height-BorderClearance) && maxTextWidth < float64(width-BorderClearance) {
return true
}
return false
}

func wrapString(text string, width int, face font.Face) string {
splitMessage := strings.Split(text, " ")
if len(splitMessage) == 1 {
return text
}

var lines []string
nextWordIndex := 0
for nextWordIndex < len(splitMessage) {
lineLength := 0.0
var line []string
for lineLength <= float64(width-BorderClearance) && nextWordIndex < len(splitMessage) {
w, _ := getTextBounds(splitMessage[nextWordIndex], face)
if w > float64(width-BorderClearance) {
return ""
}
if w+lineLength > float64(width-BorderClearance) {
break
}
lineLength += w
line = append(line, splitMessage[nextWordIndex])
nextWordIndex += 1
}
lines = append(lines, strings.Join(line, " "))
}
return strings.Join(lines, "\n")
}

func getTextBounds(text string, face font.Face) (float64, float64) {
bounds, _ := font.BoundString(face, text)

return (float64(bounds.Max.X.Round()) - float64(bounds.Min.X.Round())), (float64(bounds.Max.Y.Round()) - float64(bounds.Min.Y.Round()))
}

func ResizeImage(img image.Image, keySize int) image.Image {
Expand All @@ -189,16 +366,9 @@ func DrawProgressBar(img image.Image, label string, x, y, h, w, progress float64
}

func DrawProgressBarWithAccent(img image.Image, label string, x, y, h, w, progress float64, hex string) (image.Image, error) {
ggImg := gg.NewContextForImage(img)

f, err := truetype.Parse(goregular.TTF)
var err error

if err != nil {
return nil, err
}

face := truetype.NewFace(f, &truetype.Options{Size: h / 2})
defer face.Close()
ggImg := gg.NewContextForImage(img)

ggImg.SetFillRule(gg.FillRuleEvenOdd)

Expand All @@ -216,9 +386,19 @@ func DrawProgressBarWithAccent(img image.Image, label string, x, y, h, w, progre

ggImg.SetHexColor("#FFFFFF")

ggImg.DrawStringAnchored(label, (x+w)/2, y+(h/2), 0.5, 0.5)
img = ggImg.Image()

if label != "" {
img, err = DrawText(img, label, DrawTextOptions{
Anchor: &image.Point{
X: int(x) + int(w/2),
Y: int(y) + int((h/2)*1.2),
},
FontSize: int64(math.Max(h/2, h-5)),
})
}

return ggImg.Image(), nil
return img, err
}

func HexColor(hex string) color.RGBA {
Expand Down
Loading
Loading