From cc97d3a82ecba9d3c3b408ad5f5c9c2b888a1048 Mon Sep 17 00:00:00 2001 From: The-Jonsey Date: Mon, 6 Apr 2026 15:04:37 +0100 Subject: [PATCH 1/2] Rewrite text renderer --- go.mod | 2 +- go.sum | 2 + img.go | 204 ++++++++++++++++++++++------ img_test.go | 248 +++++++++++++++++------------------ test_resources/bottom.png | Bin 0 -> 1386 bytes test_resources/colour.png | Bin 0 -> 1406 bytes test_resources/face.png | Bin 0 -> 1265 bytes test_resources/fade.png | Bin 0 -> 999 bytes test_resources/left.png | Bin 0 -> 1004 bytes test_resources/ml-bottom.png | Bin 0 -> 1759 bytes test_resources/ml-top.png | Bin 0 -> 1759 bytes test_resources/normal.png | Bin 0 -> 1384 bytes test_resources/right.png | Bin 0 -> 997 bytes test_resources/size.png | Bin 0 -> 615 bytes test_resources/top.png | Bin 0 -> 1386 bytes vendor/modules.txt | 2 +- 16 files changed, 290 insertions(+), 168 deletions(-) create mode 100644 test_resources/bottom.png create mode 100644 test_resources/colour.png create mode 100644 test_resources/face.png create mode 100644 test_resources/fade.png create mode 100644 test_resources/left.png create mode 100644 test_resources/ml-bottom.png create mode 100644 test_resources/ml-top.png create mode 100644 test_resources/normal.png create mode 100644 test_resources/right.png create mode 100644 test_resources/size.png create mode 100644 test_resources/top.png diff --git a/go.mod b/go.mod index ccaff6d..ece40dd 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 23e2616..701f6da 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/img.go b/img.go index a3da89e..e55b497 100644 --- a/img.go +++ b/img.go @@ -26,6 +26,7 @@ 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 @@ -39,11 +40,28 @@ 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 } type IContext interface { @@ -59,22 +77,31 @@ 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().Max.X, img.Bounds().Max.Y + + 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, img, options.Overflow) if options.FontSize != 0 { fSize = float64(options.FontSize) @@ -82,18 +109,54 @@ func drawText(img IContext, text string, options DrawTextOptions) (image.Image, 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, width, face) + } + + lineCount := strings.Count(lines, "\n") + 1 + + _, y := calculateVerticalAlignment(options.VerticalAlignment, height, lineCount, fSize) + + d := &font.Drawer{ + Dst: drawImg, + Src: image.NewUniform(col), + Face: face, + } + + w, _ := getTextBounds(lines, face) + + x := calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width) + + 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) + d.Dot = fixed.Point26_6{ + X: fixed.I(calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width)), + 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 @@ -128,29 +191,39 @@ func loadFontFace(fontName string) []byte { } } -func calculateVerticalAlignment(alignment VerticalAlignment, height int) (float64, float64) { +func calculateVerticalAlignment(alignment VerticalAlignment, height, lines int, fSize float64) (float64, float64) { + textMidPoint := (float64(lines) / 2.0) * fSize if alignment == Top { - return 0.0, BorderClearance / 2 + return 0.0, (BorderClearance / 2) + (textMidPoint) } if alignment == Bottom { - return 1.0, float64(height) - (BorderClearance / 2) + return 1.0, float64(height) - (BorderClearance / 2) - textMidPoint } return 0.5, float64(height) / 2 } -func calculateFontSize(f *truetype.Font, text string, img IContext) float64 { - width := img.Width() +func calculateHorizonalAlignment(alignment HorizontalAlignment, textWidth, width int) int { + if alignment == Left { + return BorderClearance / 2 + } + if alignment == Right { + return width - (BorderClearance / 2) - textWidth + } + return ((width) / 2) - (int(textWidth) / 2) +} + +func calculateFontSize(f *truetype.Font, text string, img image.Image, overflow Overflow) float64 { + width := img.Bounds().Dx() 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, img, size, overflow) { return size } } @@ -159,16 +232,71 @@ 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, img image.Image, fSize float64, overflow Overflow) bool { + width := img.Bounds().Dx() + height := img.Bounds().Dy() 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 { diff --git a/img_test.go b/img_test.go index 11c2d23..7f58b04 100644 --- a/img_test.go +++ b/img_test.go @@ -6,13 +6,12 @@ import ( "image/draw" "log" "math/rand" + "os" "testing" "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" - "github.com/unix-streamdeck/api/v2/mocks/mock_api" "github.com/unix-streamdeck/gg" - "go.uber.org/mock/gomock" "golang.org/x/image/font/gofont/gobold" "golang.org/x/image/font/gofont/gobolditalic" "golang.org/x/image/font/gofont/goitalic" @@ -27,56 +26,25 @@ import ( "golang.org/x/image/font/gofont/gosmallcapsitalic" ) -func TestDrawText(t *testing.T) { - assertions := assert.New(t) - ctrl := gomock.NewController(t) - - context := mock_api.NewMockIContext(ctrl) - - context.EXPECT().SetRGB(1.0, 1.0, 1.0).Times(1) - - context.EXPECT().Width().Return(72).Times(2) - context.EXPECT().Height().Return(72).Times(1) - - context.EXPECT().SetFontFace(gomock.Any()).Times(2) - - context.EXPECT().MeasureMultilineString("Test", 1.0).Return(20.0, 24.0).Times(1) - - context.EXPECT().WordWrap("Test", 62.0).Return([]string{"Test"}).Times(1) - - context.EXPECT().DrawStringWrapped("Test", 36.0, 36.0, 0.5, 0.5, 62.0, 1.0, gg.AlignCenter) - - mockImg := setupImage(72, 72) - - context.EXPECT().Image().Return(mockImg).Times(1) - - img, err := drawText(context, "Test", DrawTextOptions{}) - - assertions.Equal(mockImg, img) - - assertions.Nil(err) - -} - func Test_calculateVerticalAlignment_Center(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Center, 80) + alignment, anchor := calculateVerticalAlignment(Center, 80, 1, 15) assertions.Equal(0.5, alignment) assertions.Equal(40.0, anchor) } func Test_calculateVerticalAlignment_Top(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Top, 80) + alignment, anchor := calculateVerticalAlignment(Top, 80, 1, 15) assertions.Equal(0.0, alignment) - assertions.Equal(5.0, anchor) + assertions.Equal(12.5, anchor) } func Test_calculateVerticalAlignment_Bottom(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Bottom, 80) + alignment, anchor := calculateVerticalAlignment(Bottom, 80, 1, 15) assertions.Equal(1.0, alignment) - assertions.Equal(75.0, anchor) + assertions.Equal(67.5, anchor) } func Test_calculateFontSize_SingleWord(t *testing.T) { @@ -85,7 +53,7 @@ func Test_calculateFontSize_SingleWord(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(24.0, calculateFontSize(f, "Test", ggImg)) + assertions.Equal(24.0, calculateFontSize(f, "Test", img, Wrap)) } func Test_calculateFontSize_MultiLine(t *testing.T) { @@ -94,7 +62,7 @@ func Test_calculateFontSize_MultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(24.0, calculateFontSize(f, "Lines Test", ggImg)) + assertions.Equal(24.0, calculateFontSize(f, "Lines Test", img, Wrap)) } func Test_calculateFontSize_LongMultiLine(t *testing.T) { @@ -103,7 +71,7 @@ func Test_calculateFontSize_LongMultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(15.5, calculateFontSize(f, "Multiline Overflow", ggImg)) + assertions.Equal(15.5, calculateFontSize(f, "Multiline Overflow", img, Wrap)) } func Test_attemptFontSize_SingleLine(t *testing.T) { @@ -112,7 +80,7 @@ func Test_attemptFontSize_SingleLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.True(attemptFontSize(f, "Test", ggImg, 24.0)) + assertions.True(attemptFontSize(f, "Test", img, 24.0, Wrap)) } func Test_attemptFontSize_MultiLine(t *testing.T) { @@ -121,7 +89,7 @@ func Test_attemptFontSize_MultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.True(attemptFontSize(f, "Lines Test", ggImg, 24.0)) + assertions.True(attemptFontSize(f, "Lines Test", img, 24.0, Wrap)) } func Test_attemptFontSize_Overflow(t *testing.T) { @@ -130,7 +98,7 @@ func Test_attemptFontSize_Overflow(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.False(attemptFontSize(f, "Muiltiline Overflow", ggImg, 24.0)) + assertions.False(attemptFontSize(f, "Muiltiline Overflow", img, 24.0, Wrap)) } func TestResizeImage(t *testing.T) { @@ -156,92 +124,115 @@ func TestResizeImageWH(t *testing.T) { assertions.Equal(resizedImage.Bounds().Max.Y, newHeight) } -func TestDrawText_WithColor(t *testing.T) { - assertions := assert.New(t) - ctrl := gomock.NewController(t) - - context := mock_api.NewMockIContext(ctrl) - - context.EXPECT().SetRGB(1.0, 1.0, 1.0).Times(1) - context.EXPECT().SetHexColor("#FF0000").Times(1) - - context.EXPECT().Width().Return(72).Times(2) - context.EXPECT().Height().Return(72).Times(1) - - context.EXPECT().SetFontFace(gomock.Any()).Times(2) - - context.EXPECT().MeasureMultilineString("Test", 1.0).Return(20.0, 24.0).Times(1) - - context.EXPECT().WordWrap("Test", 62.0).Return([]string{"Test"}).Times(1) - - context.EXPECT().DrawStringWrapped("Test", 36.0, 36.0, 0.5, 0.5, 62.0, 1.0, gg.AlignCenter) - - mockImg := setupImage(72, 72) - - context.EXPECT().Image().Return(mockImg).Times(1) - - img, err := drawText(context, "Test", DrawTextOptions{Colour: "#FF0000"}) - - assertions.Equal(mockImg, img) - assertions.Nil(err) +type TestParameters struct { + DrawTextOptions DrawTextOptions + ExpectedImage string + Name string + Text string } -func TestDrawText_WithFontSize(t *testing.T) { - assertions := assert.New(t) - ctrl := gomock.NewController(t) - - context := mock_api.NewMockIContext(ctrl) - - context.EXPECT().SetRGB(1.0, 1.0, 1.0).Times(1) - - context.EXPECT().Width().Return(72).Times(2) - context.EXPECT().Height().Return(72).Times(1) - - context.EXPECT().SetFontFace(gomock.Any()).Times(2) - - context.EXPECT().MeasureMultilineString("Test", 1.0).Return(25.0, 12.0).Times(1) - - context.EXPECT().WordWrap("Test", 62.0).Return([]string{"Test"}).Times(1) - - context.EXPECT().DrawStringWrapped("Test", 36.0, 36.0, 0.5, 0.5, 62.0, 1.0, gg.AlignCenter) - - mockImg := setupImage(72, 72) - - context.EXPECT().Image().Return(mockImg).Times(1) - - img, err := drawText(context, "Test", DrawTextOptions{FontSize: 16}) - - assertions.Equal(mockImg, img) - assertions.Nil(err) -} - -func TestDrawText_WithNewlines(t *testing.T) { - assertions := assert.New(t) - ctrl := gomock.NewController(t) - - context := mock_api.NewMockIContext(ctrl) - - context.EXPECT().SetRGB(1.0, 1.0, 1.0).Times(1) - - context.EXPECT().Width().Return(72).Times(2) - context.EXPECT().Height().Return(72).Times(1) - - context.EXPECT().SetFontFace(gomock.Any()).Times(2) - - context.EXPECT().MeasureMultilineString("Line1\nLine2", 1.0).Return(30.0, 48.0).Times(1) - - context.EXPECT().WordWrap("Line1\nLine2", 62.0).Return([]string{"Line1", "Line2"}).Times(1) - - context.EXPECT().DrawStringWrapped("Line1\nLine2", 36.0, 36.0, 0.5, 0.5, 62.0, 1.0, gg.AlignCenter) - - mockImg := setupImage(72, 72) - - context.EXPECT().Image().Return(mockImg).Times(1) - - img, err := drawText(context, "Line1\nLine2", DrawTextOptions{}) - - assertions.Equal(mockImg, img) - assertions.Nil(err) +func TestDrawText(t *testing.T) { + params := []TestParameters{ + { + Name: "No Params", + ExpectedImage: "normal.png", + Text: "00:00:00", + DrawTextOptions: DrawTextOptions{}, + }, + { + Name: "Colour", + ExpectedImage: "colour.png", + Text: "00:00:00", + DrawTextOptions: DrawTextOptions{ + Colour: "#CC3333", + }, + }, + { + Name: "Face", + ExpectedImage: "face.png", + Text: "Test", + DrawTextOptions: DrawTextOptions{ + FontFace: "mono", + }, + }, + { + Name: "Fade", + ExpectedImage: "fade.png", + Text: "Test", + DrawTextOptions: DrawTextOptions{ + Overflow: Fade, + }, + }, + { + Name: "Left", + ExpectedImage: "left.png", + Text: "Test", + DrawTextOptions: DrawTextOptions{ + HorizontalAlignment: Left, + }, + }, + { + Name: "Right", + ExpectedImage: "right.png", + Text: "Test", + DrawTextOptions: DrawTextOptions{ + HorizontalAlignment: Right, + }, + }, + { + Name: "Size", + ExpectedImage: "size.png", + Text: "00:00:00", + DrawTextOptions: DrawTextOptions{ + FontSize: 12, + }, + }, + { + Name: "Bottom", + ExpectedImage: "bottom.png", + Text: "00:00:00", + DrawTextOptions: DrawTextOptions{ + VerticalAlignment: Bottom, + }, + }, + { + Name: "Top", + ExpectedImage: "top.png", + Text: "00:00:00", + DrawTextOptions: DrawTextOptions{ + VerticalAlignment: Top, + }, + }, + { + Name: "Multiline Bottom", + ExpectedImage: "ml-bottom.png", + Text: "00:0\n0:00", + DrawTextOptions: DrawTextOptions{ + VerticalAlignment: Bottom, + }, + }, + { + Name: "Multiline Top", + ExpectedImage: "ml-top.png", + Text: "00:0\n0:00", + DrawTextOptions: DrawTextOptions{ + VerticalAlignment: Top, + }, + }, + } + + for _, param := range params { + t.Run(param.Name, func(t *testing.T) { + assertions := assert.New(t) + img := setupImage(72, 72) + img2, err := DrawText(img, param.Text, param.DrawTextOptions) + assertions.Nil(err) + f, err := os.Open("test_resources/" + param.ExpectedImage) + defer f.Close() + expected, _, err := image.Decode(f) + assertions.Equal(expected, img2) + }) + } } func Test_loadFontFace_Bold(t *testing.T) { @@ -411,6 +402,7 @@ func TestSubImage(t *testing.T) { assertions.Equal(50, result.Bounds().Max.X) assertions.Equal(50, result.Bounds().Max.Y) } + func setupImage(width int, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) draw.Draw(img, img.Bounds(), image.Black, image.ZP, draw.Src) diff --git a/test_resources/bottom.png b/test_resources/bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..eddcb34a7cf03df9ff6dff82f7f63865d8b089d3 GIT binary patch literal 1386 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 N22WQ%mvv4FO#l>^+w%Yb literal 0 HcmV?d00001 diff --git a/test_resources/left.png b/test_resources/left.png new file mode 100644 index 0000000000000000000000000000000000000000..04841a5f743741b24dc38577c6893745415b979d GIT binary patch literal 1004 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|YYKFmLvBaSW-L^Jd0*Z>wmL1Mh3H zA4YVq$Z=^hh+c9*NvYr>zfwW;g?a)Aje;$;^-4IsfXJnZ@tt_}{I5c1=HEses`fg`^#hM+%xd z9+A#?fi<*9nx z1b*%}Y(M<)^UosXu+>}Ft$X+9&z~1BG|cM?3m4{S>gww1oX*;M>*&#=K5C2KJo)cr zBiAmFmy{G^i;>!uV3H3d$%k|({s|LmtXGPn|Cwk z-04J#V|m*@+vsyNsd&!xdG_qtv9!$#muqWlm+h8~-F-K2{`u!+yG2*Njjr1*lAfM^ zQE1ox{roIU{{H>~PL2X@?(R?Lw6(Rhx3hCR{QbMyK;p~qzt^u{znOFG@y7)%&CShw zo{97y*Vept|GxfT*3+9bf`WrL@7&3$=%Y5-#CP$7GiP|ZUX)l>uJKy>>cxu*`BU#E z?_a**#>bC_Ef<~^J!Q+BRaaTrDA3p2yO85}p}-+vxG?Geay;w2HjJN_myv;wpTGFA zyuAFw0tto-w{LeVUCa=fnzuD7R4X*oRNU1wscP@OZQsu8KfH5i&inZ#Wo1kZPv-Bv z{IaH|W{E~tY3bLWKW{!Qiqx6*f1$b9q60xw#Lt(%dGqGPNvpZ%UVrtPq+-8!*Dj-( zKJn?R|Hv6j^{$xG*x0C)o}HgRfByXad-k|62d|t`WAfLrT1Tq4uC9)Wp{lB?OQxv2 z{CHtZdHMGfCpt8`dV6~xKYqOQ$_H81&eYA3SIpGZ)GnlH&-?qgcK>~T1_KEm4W+I} z_f7u>J-89@O62tE(@hF9eAL^{{1+NVJFwwIMyMSYY<1_Cln56|@)8t0}pH zC{u$-5X_3~m>850g<%g1vXU*S@UhK@w(AdG|KH7F&i(dq1dqS-d9`z$@4fH2cAxw9 zoUaU-qezX34XH7)AvGp8q{hUC)R@?i8WS5*V`4*UOl(Mvi4CbSu^}}kHl)VHhCG2W z48t5geAr^KXtmmbfq{;Wj+&a9{{H@{N~6(OQc{wen;Qy+-n@C^cDp})`XsoEFr=iU zw6?awB!B<@#Sh=Ve_yj^O_H0dzjp52DU-?a z^71-5Ism}q@hB7uDk~`|!IsL(%2<|7OG~S+t_Fb7(b2VQ*9zq#7<#=Pe}4WCpP89C zG&BSNXU?3#id-)L`t>USxLhtOV;JVut5*Qf(9j@=i;z@SRsw*}=bJZg9>0F)&K&^Y zI1UfZW;5PAVRw$rX2Tm&rBWsD5}o*V#ckWR5klJ9+JeC#zm8Y1dGltaQb}cfeSO{C z-BV>srP63L(qd<4XHQSh=g*(xbrBM;*9!obE?wdmold9U@5d>^^>g>`T>yCY>=|EX zu~^0?0f5rdQdEsv)oT2~tX3;lPM$m&2n5V#Gs7@=s<^nA5Q3Y8+wDf#x^?RY2M5c` z%LyURpFanH>({T-uT>}%N~MzSb+NldCyqbRa{2$oc-tI2c+ll?6&4oaY6bwAOr}<= zsS;&*d3h}@EpE5_^5x5f5L`AWL&nC&5_5@89Jk{5_&9!GSvEgEzqYp4Znr;p@BsG% znM_6q@%#N$$+E0gt9|n1Npo}a`Sa&-EbfOWOVUM10)YSk)YjGx4GlRQ4qDI5%*30d zprC*d($dlb0PXGV@7}#@Y;5H3+8;lD1OTVgnY@dTba!{-SBG8M`t|FvuG8rVAug8- z4>^wGZ_P59Y-D5v08XDioxF>X)YsPoz|;p>yWI`|!^6Y#=g+5y0HD=s`7*s;kM&KP zHYM*OBqvUsz$d_rj0}Fgxw#nt8XFt&FefJm8#r|65MSnSH~`@H@81j)ZEeTsA|$D) zsXu@I1c2)5YFadzOrcN+0Cw-*O^dk9y1Kepmc`1FB}@AH`Tzj8N6PEhuX}rYdwO~j za1j!#)rzxGUte#vS}Q6l1_lNI;Qsyle3`*u7#U6sC@$t8B-*$C%&4kN;i#Z$SNR5e&IosIG=86$n zxNxD(W;2;g8jZ&1^EEX!)z#I7r2w-!SB!|kV88&2KfJB2EhQyIK$k>FCh=og_QQt{ z0MOalsnh8ghS|P-J0={rZ{HTmB@vQL{7#)Zg_-G!6)R|Q-@bhS5DJCpY|P$VaXL7T z1Au$??uFIAeftIgm6er(xF9o6jYf0s+&Mx>dU|?lYU-@c74f!FPD0m7K|uj(mM>q9 z`NyqWx4wM&l9iQ}pP!GXva+&-$`#|7v51LZad9!narkb_4-A9B;OHcDorDd)diCm_ zJ$rWT+U50nsYb0<$K?|3YGZFK$;6L8yu7^p`0?Xrvl;&vL0=#Je*dh_6(h27<3`j} zRaN=@{;gZL(o;o6MR*FQV)o{W5m~x)DQYGrCid^&&p&nH!UX{EdcDcJBuJ8pAI2Ls zH8l~x_R*t9g18{@ZTF8KKPH5%UcEXj@xvK-`t<3<#KgZ24@*lG3dP>NdkG;AA3hYs zB@tpz{H#`M?<8eqWwc0_Sz%!zEnd8M5$7Q-EiGObA&D{ZqbYYqMFqcp!=Kf-T%s_ux#DzWWMpjF zvL!t|-Rt#o95*sDG7~OylQ|paNR5e&{{;X5|Nl5HyrAfAwU+<@002ovPDHLkV1jU= Ba@+s_ literal 0 HcmV?d00001 diff --git a/test_resources/ml-top.png b/test_resources/ml-top.png new file mode 100644 index 0000000000000000000000000000000000000000..9e71d0c54874c3e298586fa596d08427b0f620cf GIT binary patch literal 1759 zcmV<51|a!~P)^{{1+NVJFwwIMyMSYY<1_Cln56|@)8t0}pH zC{u$-5X_3~m>850g<%g1vXU*S@UhK@w(AdG|KH7F&i(dq1dqS-d9`z$@4fH2cAxw9 zoUaU-qezX34XH7)AvGp8q{hUC)R@?iCoqO#n8Sw;TPzl>Ry!~-(9zLRQ&ZF5-#=Ap zG#X1vN^)~^L!r={H*ef-_oq*v1a}dJl$4a#)>fG0@87@p;rsXRYu2m@Q#zeaJR1xK z@k1aGICA8Our7je^X5$e`1R}8&Ye4DGFe_;UPng<0C+qeg+f7PB_$=;QdwCU%d%-{ zY1P%$05Cc_x_0eaplFG_T0Py*I^XARt*YDi90{|Sy;i1`V#+xVX&av5SctfgGs^ndw6W^}5 zZQC|NNLyQ5Fc{?5@d`F?-mFwAsjRQBue-Z@s!XX=8jVI;?Ck99>FN3W`E$H3LgMv$ z0pQZ5OZ=kK>Gb>kI7PUA?%ur%0MDL1py<{z#Sr9m*~VdW6PE; zBZT;TK3Z`&9Co|?z<~pJxN6m^h%%GOR99D5T3U+RJ<5`F5e&6jjX#*xYQ@UQlP3d# zfZ1$j7zR%j7Z(#kaFcMm-6&hPZr$MEV0n2tA>{e<=Kyg1`gQuX3WY+cRMNdJc9-bH z@dsKi|GyY-n}Y`rx?HZp!a`il03ege)M_*J$p|5Szn>~umep#tPo6w!Zf-t*{ydJw z{SakIx(G=i5CDMM+S;L^A&0|3>zSFEc#{+q6c9pMT3P_0y}kY2yLXL^jr?8vjEn%l>C>l^cM+2M`g#DE z`XFn!+W}yBczFK&`ScI~v|24+rq}DSzG>5@RpVr?%lg-5tmt4 zR~O5&SXr`UNnc+d0O0mWdHwozZ*Ol;Pfr3aLSnUAaW?Af>#bI6MMcHHzyJW;zki=E zGZ+lR!@~gZ^5sjL&1SdTaog~CJQ|ILublei5xa}P96NSwaBvX*As7t0-R`N+wHr2U z@OV7HqrJU7J3Bi}7O}C|U8X~pSZ^wo%4{~TT)DEpzyHOH7rni`5oHX+7>!1qPB%V2 z{`T$LuCA_`aQSaBXTuz+F|jdc8=Ki&F(L~WF0|QfCX-2{(fEA6rlzL4y1K9wU{>df z5iuAH7+~>-x3#sUq@)Pwk_gEpek{v=`0xP$Iy*acIvv9>+qZAWgyZ(@+d{b{LXwH! zsZ*ygGhMM_1ugE|w+{e9p%9&o*_$g)2gh*$aPQu|u==-e-vFSpvQiKiWJYpDLdemh zM|&orNt;|Q$Jemd)>i)5MT-^%0s%tEjvYJVbrGyFyvm8z+!3GYmU!4R*DV2ve{_~BUoaJgLG(9odKXwIEG zM+iwzPftxvoz=M_-Zsif=sGDVC_v5f<;yYuxOMB+moHzkva<5?^YK(xR+dn?VjMFT zG4U%dF6KB6-);GUVK5jRorJEFu;Ev)Ufr{2&#qm&yk0NWsMYGYT%uiV?2RRv`0!aWApVhfyL^f{Rh?=UZD!<>qb?a7os;H<4PvKO|-dr&vOP4N1 z&BVmS{{8#;r!HK$003UEH+h!?Niy-nc%!DKCgRsVdh|#T7bL#z{_*3-gpk#%SBE8j zI0H|gKAo7D_}AfKX{kb?*t>TxA>`r1hl02yLhOm3)oSgXq^zur7U?o8EG(qOix)5A zJfx+i#p@y@F(!UA<*ulx;MZ^5xB&oTV`EpYTq!Cls;a8O%RPJcYyvI<6HDSZU1KcE zUb}XUzG4BupFe;2vl^F66lOM8oQ{l)j4fNXq^GBQyv zCN`wT#D>(E*pM0%8&YFpLuyQHNR5e&{{;X5|NnIOyr5@Iv#bCB002ovPDHLkV1l>H BZxa9j literal 0 HcmV?d00001 diff --git a/test_resources/normal.png b/test_resources/normal.png new file mode 100644 index 0000000000000000000000000000000000000000..44d4926ff7b6749a7975b8c28d1785a1520d0c0f GIT binary patch literal 1384 zcmZuxeK^x=82;gkC278nS=2aUmapQ-(bP%HOpGLit=i;cSc)|Au~2GlB;-QK*BPg> zi`teW){c{Bmr9Og(M?fy)OZ?u6gOjH#aOOA*a@|pueQ2}*9Yjj2y zZz<@}z|lwJsZjE%JBU+gq{m(wifd@6K&Tzo6+%1<5e+xB_MN!3Rg+Be$3e3dm+^q}aQ%!VkXzkebQMtM(9~xpB z`uO@5i$5u4J&C5qw=koEOwQXWiYMc7ieloJMS&xnWmRZXE*X8EVt}2UokjNuQWq(k z8|x+Z1P_P_tLr`K!dS=RKkF+`@a*H3`d8+pjV7nw4h{yPiouo?3ChnBNVS1Az5%PP zqp9H${dKgRMpG@s)J)%c=@iY^0%T$u;Luc-^9^m8WS1yTMTU9N}Tf=pY zwdflg89BY+@M;J+>rm=~7nMvRshdB&EFYxk)t|p`!2!Tn=%Js}6r|u_V{r?CKtSmM zGo$Sz5&OA({$55jH?-|9dx-j>r(oXnaE3Hue|JTiYN0WpaeTG|B;e30NJ&_GDp%Uo z+uJM5)>8ZFqD>J=B9Q=hyz7DS{>GDLW@g6r;Q$@PqZk52Mn<~kZOiSvRFLE*abylq zxWjRNm1vq1SViRz&dv=OU_7}k-em+7OhSQoeHOGO#Fofe?qcn{YiW17 z1>>$x2Y(%ob7?g=VOU;PW~wVHb&68IMz_Qb#K%dWU8GW}V;$+;5dqYrKNHP^kcI$# z@)b*XuZAcQH)52x9d!~vy*8?pvDs_|;k8UT-M408XJ@zkb*!qaEOle; zN1_eC`CO-Fw$RbdO*K1mAg$Rb9{IAS51H}1hK7b9WBoVTPJq-5BR7HXB4&pKA% zGgtmLFYF#K6$*v^NJ~hTcO7yfxlQebO!>iF+>)N2P7Bzh8nYmBf=7E*s?=7mYJb}D zT!mEAEuoJa11X|BGujF$rVv{mWdL>K zTobXK!NEwrfSa-srY2BaYQ6t z$nvZmej0O^$`Jf_LxY}UUE;-L2c literal 0 HcmV?d00001 diff --git a/test_resources/right.png b/test_resources/right.png new file mode 100644 index 0000000000000000000000000000000000000000..2dd40bbcc183442209ff4ca64ba750978432157c GIT binary patch literal 997 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 literal 0 HcmV?d00001 diff --git a/vendor/modules.txt b/vendor/modules.txt index 056784d..ab3966d 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -24,7 +24,7 @@ github.com/unix-streamdeck/gg # go.uber.org/mock v0.6.0 ## explicit; go 1.23.0 go.uber.org/mock/gomock -# golang.org/x/image v0.37.0 +# golang.org/x/image v0.38.0 ## explicit; go 1.25.0 golang.org/x/image/draw golang.org/x/image/font From 38f3d12681bf2002d6b397bf541368869b4bd60b Mon Sep 17 00:00:00 2001 From: Louis Jones Date: Tue, 7 Apr 2026 18:18:24 +0100 Subject: [PATCH 2/2] Add arbitrary position anchoring --- img.go | 122 +++++++++++++++++------- img_test.go | 44 ++++++--- test_resources/anchor/LEFT-BOTTOM.png | Bin 0 -> 336 bytes test_resources/anchor/LEFT-CENTER.png | Bin 0 -> 336 bytes test_resources/anchor/LEFT-TOP.png | Bin 0 -> 336 bytes test_resources/anchor/MIDDLE-BOTTOM.png | Bin 0 -> 336 bytes test_resources/anchor/MIDDLE-CENTER.png | Bin 0 -> 336 bytes test_resources/anchor/MIDDLE-TOP.png | Bin 0 -> 336 bytes test_resources/anchor/RIGHT-BOTTOM.png | Bin 0 -> 337 bytes test_resources/anchor/RIGHT-CENTER.png | Bin 0 -> 337 bytes test_resources/anchor/RIGHT-TOP.png | Bin 0 -> 336 bytes 11 files changed, 119 insertions(+), 47 deletions(-) create mode 100644 test_resources/anchor/LEFT-BOTTOM.png create mode 100644 test_resources/anchor/LEFT-CENTER.png create mode 100644 test_resources/anchor/LEFT-TOP.png create mode 100644 test_resources/anchor/MIDDLE-BOTTOM.png create mode 100644 test_resources/anchor/MIDDLE-CENTER.png create mode 100644 test_resources/anchor/MIDDLE-TOP.png create mode 100644 test_resources/anchor/RIGHT-BOTTOM.png create mode 100644 test_resources/anchor/RIGHT-CENTER.png create mode 100644 test_resources/anchor/RIGHT-TOP.png diff --git a/img.go b/img.go index e55b497..9bcb6d1 100644 --- a/img.go +++ b/img.go @@ -31,7 +31,6 @@ import ( const BorderClearance = 10 -// TODO replace use of gg with native font.Drawer type VerticalAlignment string const ( @@ -62,6 +61,8 @@ type DrawTextOptions struct { 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 { @@ -88,7 +89,21 @@ func DrawText(img image.Image, text string, options DrawTextOptions) (image.Imag return img, errors.New("cannot convert") } - width, height := img.Bounds().Max.X, img.Bounds().Max.Y + 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} @@ -101,7 +116,7 @@ func DrawText(img image.Image, text string, options DrawTextOptions) (image.Imag if err != nil { return nil, err } - fSize := calculateFontSize(f, text, img, options.Overflow) + fSize := calculateFontSize(f, text, availableWidth, availableHeight, options.Overflow) if options.FontSize != 0 { fSize = float64(options.FontSize) @@ -113,12 +128,24 @@ func DrawText(img image.Image, text string, options DrawTextOptions) (image.Imag lines := text if options.Overflow == Wrap { - lines = wrapString(text, width, face) + lines = wrapString(text, availableWidth, face) } lineCount := strings.Count(lines, "\n") + 1 - _, y := calculateVerticalAlignment(options.VerticalAlignment, height, lineCount, fSize) + 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, @@ -126,10 +153,6 @@ func DrawText(img image.Image, text string, options DrawTextOptions) (image.Imag Face: face, } - w, _ := getTextBounds(lines, face) - - x := calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width) - d.Dot = fixed.Point26_6{ X: fixed.I(x), Y: fixed.I(int(y) + (int(fSize) / 4)), @@ -150,8 +173,15 @@ func DrawText(img image.Image, text string, options DrawTextOptions) (image.Imag 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(calculateHorizonalAlignment(options.HorizontalAlignment, int(w), width)), + X: fixed.I(x), Y: fixed.I(int(startingLineY + (float64(i) * fSize))), } d.DrawString(line) @@ -191,29 +221,50 @@ func loadFontFace(fontName string) []byte { } } -func calculateVerticalAlignment(alignment VerticalAlignment, height, lines int, fSize float64) (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) + (textMidPoint) + return float64(height) + textMidPoint } if alignment == Bottom { - return 1.0, float64(height) - (BorderClearance / 2) - textMidPoint + return float64(height) - textMidPoint } - return 0.5, float64(height) / 2 + return float64(height) } -func calculateHorizonalAlignment(alignment HorizontalAlignment, textWidth, width int) int { +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) + } + if alignment == Left { - return BorderClearance / 2 + return width } + if alignment == Right { - return width - (BorderClearance / 2) - textWidth + return width - textWidth } - return ((width) / 2) - (int(textWidth) / 2) + + return width - (textWidth / 2) } -func calculateFontSize(f *truetype.Font, text string, img image.Image, overflow Overflow) float64 { - width := img.Bounds().Dx() +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() @@ -223,7 +274,7 @@ func calculateFontSize(f *truetype.Font, text string, img image.Image, overflow 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, overflow) { + if attemptFontSize(f, text, width, height, size, overflow) { return size } } @@ -232,9 +283,7 @@ func calculateFontSize(f *truetype.Font, text string, img image.Image, overflow return fSize } -func attemptFontSize(f *truetype.Font, text string, img image.Image, fSize float64, overflow Overflow) bool { - width := img.Bounds().Dx() - height := img.Bounds().Dy() +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() w, h := getTextBounds(text, face) @@ -317,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) + var err error - f, err := truetype.Parse(goregular.TTF) - - 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) @@ -344,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 { diff --git a/img_test.go b/img_test.go index 7f58b04..83455ed 100644 --- a/img_test.go +++ b/img_test.go @@ -28,22 +28,19 @@ import ( func Test_calculateVerticalAlignment_Center(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Center, 80, 1, 15) - assertions.Equal(0.5, alignment) + anchor := calculateVerticalAlignment(Center, 80, 1, 15, false) assertions.Equal(40.0, anchor) } func Test_calculateVerticalAlignment_Top(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Top, 80, 1, 15) - assertions.Equal(0.0, alignment) + anchor := calculateVerticalAlignment(Top, 80, 1, 15, false) assertions.Equal(12.5, anchor) } func Test_calculateVerticalAlignment_Bottom(t *testing.T) { assertions := assert.New(t) - alignment, anchor := calculateVerticalAlignment(Bottom, 80, 1, 15) - assertions.Equal(1.0, alignment) + anchor := calculateVerticalAlignment(Bottom, 80, 1, 15, false) assertions.Equal(67.5, anchor) } @@ -53,7 +50,7 @@ func Test_calculateFontSize_SingleWord(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(24.0, calculateFontSize(f, "Test", img, Wrap)) + assertions.Equal(24.0, calculateFontSize(f, "Test", 72, 72, Wrap)) } func Test_calculateFontSize_MultiLine(t *testing.T) { @@ -62,7 +59,7 @@ func Test_calculateFontSize_MultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(24.0, calculateFontSize(f, "Lines Test", img, Wrap)) + assertions.Equal(24.0, calculateFontSize(f, "Lines Test", 72, 72, Wrap)) } func Test_calculateFontSize_LongMultiLine(t *testing.T) { @@ -71,7 +68,7 @@ func Test_calculateFontSize_LongMultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.Equal(15.5, calculateFontSize(f, "Multiline Overflow", img, Wrap)) + assertions.Equal(15.5, calculateFontSize(f, "Multiline Overflow", 72, 72, Wrap)) } func Test_attemptFontSize_SingleLine(t *testing.T) { @@ -80,7 +77,7 @@ func Test_attemptFontSize_SingleLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.True(attemptFontSize(f, "Test", img, 24.0, Wrap)) + assertions.True(attemptFontSize(f, "Test", 72, 72, 24.0, Wrap)) } func Test_attemptFontSize_MultiLine(t *testing.T) { @@ -89,7 +86,7 @@ func Test_attemptFontSize_MultiLine(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.True(attemptFontSize(f, "Lines Test", img, 24.0, Wrap)) + assertions.True(attemptFontSize(f, "Lines Test", 72, 72, 24.0, Wrap)) } func Test_attemptFontSize_Overflow(t *testing.T) { @@ -98,7 +95,7 @@ func Test_attemptFontSize_Overflow(t *testing.T) { ggImg := gg.NewContextForImage(img) ggImg.SetHexColor("#FFF") f, _ := truetype.Parse(goregular.TTF) - assertions.False(attemptFontSize(f, "Muiltiline Overflow", img, 24.0, Wrap)) + assertions.False(attemptFontSize(f, "Muiltiline Overflow", 72, 72, 24.0, Wrap)) } func TestResizeImage(t *testing.T) { @@ -235,6 +232,29 @@ func TestDrawText(t *testing.T) { } } +func TestDrawText_Anchor(t *testing.T) { + for _, valign := range []VerticalAlignment{Top, Center, Bottom} { + for _, halign := range []HorizontalAlignment{Left, Middle, Right} { + t.Run(string(valign)+"-"+string(halign), func(t *testing.T) { + assertions := assert.New(t) + img := image.NewRGBA(image.Rect(0, 0, 72, 72)) + draw.Draw(img, img.Bounds(), image.Black, image.ZP, draw.Src) + actual, _ := DrawText(img, "Hi", DrawTextOptions{ + FontSize: 15, + Anchor: &image.Point{X: 20, Y: 20}, + Overflow: Fade, + VerticalAlignment: valign, + HorizontalAlignment: halign, + }) + f, _ := os.Open("test_resources/anchor/" + string(halign) + "-" + string(valign) + ".png") + defer f.Close() + expected, _, _ := image.Decode(f) + assertions.Equal(expected, actual) + }) + } + } +} + func Test_loadFontFace_Bold(t *testing.T) { assertions := assert.New(t) assertions.Equal(gobold.TTF, loadFontFace("bold")) diff --git a/test_resources/anchor/LEFT-BOTTOM.png b/test_resources/anchor/LEFT-BOTTOM.png new file mode 100644 index 0000000000000000000000000000000000000000..4e783fc6175d3d9632e9f391f0752c3d3db7b22a GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|YYKFp7J+IEGZrd2`ipovVY$v5zNR zOt>~OUhKFeCL)%=o20&Np}^MIjGV@>#X-I+D~@KnRJ0~f`hN2J>O8-q=TGJf=&nMu#%idkw)uWZZBy?ySp z&Ye#UCU5djZwd)bEj=o``+bB&+p!soG}h$p{Bo$5)p5e}pM{Thy?f8UcI)%XzU1#a zcYHZ?O2u>5GE2Xj`_tvMzc}5n=!={ttNwb|seaK%Y2xL`mLaW?&M e9WZqM|DO<`{l3Rg~OUhKFeCL)%=o20&Np}^MIjGV@>#X-I+D~@KnRJ0~f`hN2J>O8-q=TGJX?I&0qc#1*fjG^Z|mx$k{yB-eJE2OR(9T_+koGwFF;F-vXfm2J7X zx6ggnx$~*P%MI!<{0v+&WbcklVvZhcX6aXRf4aQ(7pEH*eUZ~-)nD&A)i3&}oQL=Di}^p^^cn&g7YxWC h&ZZx(1BTB3|C!6Q-?PUX^a4YQ!PC{xWt~$(69DM(lz9LE literal 0 HcmV?d00001 diff --git a/test_resources/anchor/LEFT-TOP.png b/test_resources/anchor/LEFT-TOP.png new file mode 100644 index 0000000000000000000000000000000000000000..e08499999b5a4985b30325f9159381de524255e5 GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|YYKFp7J+IEGZrd2`k9oQs3Rv5zNR zOt=aeFLqoK72$ip^GI#oLV>N(88MAvi-UYu{^(jSxG}oIcT>O3{;bbx*7^S=omNjz zl8|a!n214SM%@1%dwp)(VS_8BYo~Z!F1i1GlTK@NUO~(M{zWGwilvSf+W1Zm%DTO6 zTki8pvAms*Qs3@w(g_WXjQ!Yow_JzEIoT&rBy4+3)q`VA3MW4Qu_%oF{=fa&tzbx!w9E{%P9oa>`?TU+86<+$o(9_PabCL|H{ ej0_C_|Fd7!ejl;8>~ftHnX&*vCYH zZpS?h5l3E#^2i-9?lAu-#b#K|-5q`*%W$Telvr7}gjm|Q*1u1FFMYa^=lyw&tgit) z&WAT_FO+Fd+`+p%GwXJk=G5muE9aHQcIUk~vH4f|B$Z<`ZX5VsPK}%rbiD9O?f!ME zU$phTIkfNgwzZj32bt>?fA$?M%;{OV%Ikwq`ZaSg*TBeW*E`?;o-ZHuT7kpH|C44x z+FqAwsj=5%)e|MU)~Ot}%9;0kk;b~owf9}aCmnn*A=P$qe#M(!LmuA47YryO&L$s@ eV`O0X|9?)W_WQPC*0;crV(@hJb6Mw<&;$VTOph}F literal 0 HcmV?d00001 diff --git a/test_resources/anchor/MIDDLE-CENTER.png b/test_resources/anchor/MIDDLE-CENTER.png new file mode 100644 index 0000000000000000000000000000000000000000..ad534c0bc46c6fcfc94d8997d2fc3eec55bf794b GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|YYKFp7J+IEGZrd2>~ftHnX&*vCYH zZpS?h5l3E#^2i-9?lAu-#b#K|-5q`*%W$Telvr7}gjm|Q*1u1FFMYa^=lyw&tgit) z&W8<5V8n*)g);4lJ9w98X59|cocjD{<-F3^?z|T#HvcN0q;hP=Z3ExSsgYBHju(EZ z-M?=2i?+TuhxXmxwl-7hAalLq&%UFDIXx>^d42Fnzh*Ax8W=h4dguG!^W~#nD{$EO zf6^>S+v_qdHTHU}dZI+vI+ep#IrE+`(pWdS_P%TQq=WAzq}ndduXxjI$isX1f&m%C i+2q4~9RjY%@v5$up z#F}?7ZtS?ID-qL>El~c*j3=o~Tx|P-t4T(_CVM_~-T9F4Y;J-5&$^dIJ9wU-*O02z zY+INpA%#H{a?gHVIcr(}!%aH1_q#nJr+u&8p1XS2!sQ2E-jAFXnQh-zIWPaVn$q>s z-p4!M?|#)b@6MrrWisy9OS{jrUsKkC>!V8f&kfDDm%~!7-OW z5tdp0jpol)yB0~bJ+$n698sz&doJxoZ+%-SGs3_boFyt=akR{0Iy1t1poj5 literal 0 HcmV?d00001 diff --git a/test_resources/anchor/RIGHT-BOTTOM.png b/test_resources/anchor/RIGHT-BOTTOM.png new file mode 100644 index 0000000000000000000000000000000000000000..d22eb4d64f0436671a7c12c4d9c312722162cd97 GIT binary patch literal 337 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|YYKFiLp3IEGZrd2?0qREvYev5(Uo zb=W>KB?&GLH>hAZuW;{3w#1P)x;k-=tBKB?&GLH>hAZuW;{3w#1P)x;k-=tBD;6rgf3{o2bIUdP<0ih-Qw1kJ zw)nL#{`%FAPR`|t-?!yv&pP%>V)mK(&e&`G4{S3Agx1TlJ3_nD96s jCWlzh`~ftHnX$=*L8X zZpS?h5l3E#^2i-9?lAu-#b#K|-5q`*%W$Telv$48VUEK*Z;J2h>)xOCJv}0iC$o!Yb~uIB##x(PvSjt?rI z<-fafc#Y2KUF&wo9Wp4fIoa2~p>kgLq8)MHYRiGV{eR?}j~V