Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Text centering issues with bitmapfont #69

Closed
quasilyte opened this issue May 16, 2023 · 16 comments
Closed

Text centering issues with bitmapfont #69

quasilyte opened this issue May 16, 2023 · 16 comments

Comments

@quasilyte
Copy link
Contributor

quasilyte commented May 16, 2023

  1. Modify the button widget example as follows:
--- a/_examples/widget_demos/button/main.go
+++ b/_examples/widget_demos/button/main.go
@@ -7,10 +7,8 @@ import (
        "github.com/ebitenui/ebitenui"
        "github.com/ebitenui/ebitenui/image"
        "github.com/ebitenui/ebitenui/widget"
-       "github.com/golang/freetype/truetype"
+       "github.com/hajimehoshi/bitmapfont/v2"
        "github.com/hajimehoshi/ebiten/v2"
-       "golang.org/x/image/font"
-       "golang.org/x/image/font/gofont/goregular"
 )
 
 // Game object used by ebiten
@@ -22,8 +20,7 @@ func main() {
        // load images for button states: idle, hover, and pressed
        buttonImage, _ := loadButtonImage()
 
-       // load button text font
-       face, _ := loadFont(20)
+       face := bitmapfont.Face
 
        // construct a new container that serves as the root of the UI hierarchy
        rootContainer := widget.NewContainer(
@@ -49,7 +46,7 @@ func main() {
                widget.ButtonOpts.Image(buttonImage),
 
                // specify the button's text, the font face, and the color
-               widget.ButtonOpts.Text("Hello, World!", face, &widget.ButtonTextColor{
+               widget.ButtonOpts.Text("000\n111\n222", face, &widget.ButtonTextColor{
                        Idle: color.NRGBA{0xdf, 0xf4, 0xff, 0xff},
                }),
 
@@ -121,16 +118,3 @@ func loadButtonImage() (*widget.ButtonImage, error) {
                Pressed: pressed,
        }, nil
 }
-
-func loadFont(size float64) (font.Face, error) {
-       ttfFont, err := truetype.Parse(goregular.TTF)
-       if err != nil {
-               return nil, err
-       }
-
-       return truetype.NewFace(ttfFont, &truetype.Options{
-               Size:    size,
-               DPI:     72,
-               Hinting: font.HintingFull,
-       }), nil
-}

Basically, replace loadFont() with a direct use of bitmapfont.Face.

  1. Run the example.

image

The result is off-center text. This happens with any kind of text with this font.
The font is monospaced.

It could be a bitmapfont issue or ebitenui font handling issue.
It's more likely to be a bitmapfont issue, but it's hard to debug it outside of the context.
For instance, maybe it handles glyph advance incorrectly, providing a spacing for 1 extra trailing character?
Or maybe ebitenui doesn't handle this corner case correctly with different font.Face implementations?

The underlying font.Face implementation can be found here: https://github.com/hajimehoshi/bitmapfont/blob/main/internal/bitmap/bitmap.go

I'll link this issue to the bitmapfont repository issue as well.

@hajimehoshi
Copy link

Isn't \n rendered unexpectedly?

@quasilyte
Copy link
Contributor Author

quasilyte commented May 16, 2023

The issue persists even without a newline.
image
I can also confirm that this issue can be reproduced by other widgets tool, like widget.Text:
image
The text is supposed to center-aligned inside a window, but it's not.

I would assume that this is a relevant piece of code:

ebitenui/widget/text.go

Lines 267 to 341 in 507f5ce

func (t *Text) measure() {
if t.Label == t.measurements.label && t.Face == t.measurements.face && t.MaxWidth == t.measurements.maxWidth {
return
}
m := t.Face.Metrics()
t.measurements = textMeasurements{
label: t.Label,
face: t.Face,
ascent: fixedInt26_6ToFloat64(m.Ascent),
maxWidth: t.MaxWidth,
}
fh := fixedInt26_6ToFloat64(m.Ascent + m.Descent)
t.measurements.lineHeight = fixedInt26_6ToFloat64(m.Height)
ld := t.measurements.lineHeight - fh
s := bufio.NewScanner(strings.NewReader(t.Label))
for s.Scan() {
if t.MaxWidth > 0 {
var newLine []string
var newLineWidth float64 = float64(t.Inset.Left + t.Inset.Right)
words := strings.Split(s.Text(), " ")
for _, word := range words {
wordWidth := fixedInt26_6ToFloat64(font.MeasureString(t.Face, word+" "))
//Strip out any bbcodes from size calculation
if t.processBBCode {
if t.bbcodeRegex.MatchString(word) {
cleaned := t.bbcodeRegex.ReplaceAllString(word, "")
wordWidth = fixedInt26_6ToFloat64(font.MeasureString(t.Face, cleaned+" "))
}
}
//If the new word doesnt push this past the max width continue adding to the current line
if newLineWidth+wordWidth < t.MaxWidth {
newLine = append(newLine, word)
newLineWidth += wordWidth
} else {
//If the new word would push this past the max width save off the current line and start a new one
if len(newLine) != 0 {
t.measurements.lines = append(t.measurements.lines, newLine)
t.measurements.lineWidths = append(t.measurements.lineWidths, newLineWidth)
if newLineWidth > t.measurements.boundingBoxWidth {
t.measurements.boundingBoxWidth = newLineWidth
}
}
newLine = []string{word}
newLineWidth = wordWidth + float64(t.Inset.Left+t.Inset.Right)
}
}
//Save the final line
if len(newLine) != 0 {
t.measurements.lines = append(t.measurements.lines, newLine)
t.measurements.lineWidths = append(t.measurements.lineWidths, newLineWidth)
if newLineWidth > t.measurements.boundingBoxWidth {
t.measurements.boundingBoxWidth = newLineWidth
}
}
} else {
line := s.Text()
t.measurements.lines = append(t.measurements.lines, []string{line})
lw := fixedInt26_6ToFloat64(font.MeasureString(t.Face, line)) + float64(t.Inset.Left+t.Inset.Right)
t.measurements.lineWidths = append(t.measurements.lineWidths, lw)
if lw > t.measurements.boundingBoxWidth {
t.measurements.boundingBoxWidth = lw
}
}
}
t.measurements.boundingBoxHeight = float64(len(t.measurements.lines))*t.measurements.lineHeight - ld
}

The line width is calculated as 378 for both bitmap font and the font used in the example, but they're still aligned differently:
image

Ultimately, both fonts/texts end up here, with lx=11:

text.Draw(screen, strings.Join(line, " "), t.Face, lx, ly, t.Color)

It's weird that the results are quite different.
image

So, with lx=11 we still get ~6px offset from the left with a bitmap font.

@quasilyte
Copy link
Contributor Author

Here is a code to play around this without ebitenui:

package main

import (
	"fmt"
	"image/color"
	"log"

	"github.com/hajimehoshi/bitmapfont/v2"
	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/text"
	"github.com/hajimehoshi/ebiten/v2/vector"
)

type Game struct {
}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	ff := bitmapfont.Face
	white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
	green := color.RGBA{G: 255, A: 255}
	offsets := []int{10, 30, 50, 70}
	dy := 20
	for _, dx := range offsets {
		text.Draw(screen, fmt.Sprintf("Hello, offset_x=%d", dx), ff, dx, dy, white)
		dy += 20
		vector.StrokeLine(screen, float32(dx), 0, float32(dx), float32(200), 1, green, true)
	}
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return 640, 480
}

func main() {
	ebiten.SetWindowSize(640, 480)
	ebiten.SetWindowTitle("bitmapfont")
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}

image

@hajimehoshi
Copy link

hajimehoshi commented May 16, 2023

This seems correct as the green line should indicate a dot position. What about using . instead of H?

@quasilyte
Copy link
Contributor Author

The dot position looks good.
Although I'm so confused now. 😅
It looks like the line at x=70 has offset of ~85 pixels.
image
I tried using fixed-size layouts (just returning 640, 480) as well as this:

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return outsideWidth, outsideHeight
}

Maybe some scaling is still going on there.

@hajimehoshi
Copy link

hajimehoshi commented May 17, 2023

Hmm, interestingly, font.BoundString doesn't use an origin position as a dot position with other fonts like Noto Sans CJK JP. (e.g. with 32-size Noto Sans CJK JP, b, _, _ := f.Glyph('.'); b.Min.X was a positive value, which was close to 0. I epxected this a negative value as the center should be (0, 0).)

@quasilyte
Copy link
Contributor Author

quasilyte commented May 28, 2023

The GlyphBounds could be the culprit here too.
The default ebitenui example font (centered correctly) bounds: {{0:00 -6:00} {6:00 0:00}}
The bitmap font bounds: {{-4:00 -12:00} {2:00 4:00}}
Notice the -4 x-axis offset.
If I add 4 to the text.Draw() x argument, the result looks more centered.

I think that either bitmapfont should adjust the GlyphBounds-returned data or drawGlyph inside Ebitengine should handle this information differently. If the current behavior is expected, then perhaps ebitenui should provide some cludges to make such fonts center-alignable (it can detect Face.GlyphBounds returning negative x offset or something).

P.S. - the vertical alignment seems to be off too.

@quasilyte
Copy link
Contributor Author

I'm getting really weird results for multi-line texts too.
image
Notice how the Y offset of the last button label is different.
They're all supposed to be aligned to the center of the button rectangle (and they are aligned if a different font is used.
image

@mcarpenter622
Copy link
Collaborator

The more I look at this the more I think it has something to do with the dot positioning in the bitmap font lib. For all the TTF fonts I've tested it is aligned as expected. It seems like the bitmap font is expecting the draw X position to be the end of the rune while the TTFs are expecting the draw X position to be the start of the rune. I don't think this is a problem with ebitenui. As far as a workaround in the meantime you can adjust the TextPadding to be biased on the left side:

	widget.ButtonOpts.TextPadding(widget.Insets{
			Left:   34, // + 4
			Right:  26, // - 4
			Top:    5,
			Bottom: 5,
		}),

Take a look at this:
image

package main

import (
	"fmt"
	"image/color"
	"log"

	"github.com/golang/freetype/truetype"
	"github.com/hajimehoshi/bitmapfont/v2"
	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/text"
	"github.com/hajimehoshi/ebiten/v2/vector"
	"golang.org/x/image/font"
	"golang.org/x/image/font/gofont/goregular"
)

type Game struct {
}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	bmf := bitmapfont.Face
	ff, _ := loadFont(11)
	white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
	green := color.RGBA{G: 255, A: 255}
	offsets := []int{10, 30, 50, 70}
	dy := 20
	for _, dx := range offsets {
		text.Draw(screen, fmt.Sprintf("TTF Font, offset_x=%d", dx), ff, dx, dy, white)
		text.Draw(screen, fmt.Sprintf("BMF Font, offset_x=%d", dx), bmf, dx, dy+15, white)
		dy += 30
		vector.StrokeLine(screen, float32(dx), 0, float32(dx), float32(200), 1, green, true)
	}
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return 640, 480
}

func main() {
	ebiten.SetWindowSize(640, 480)
	ebiten.SetWindowTitle("bitmapfont")
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}

func loadFont(size float64) (font.Face, error) {
	ttfFont, err := truetype.Parse(goregular.TTF)
	if err != nil {
		return nil, err
	}

	return truetype.NewFace(ttfFont, &truetype.Options{
		Size:    size,
		DPI:     72,
		Hinting: font.HintingFull,
	}), nil
}

@hajimehoshi
Copy link

OK so this seems an issue in the bitmap font:

https://pkg.go.dev/golang.org/x/image/font#Face
https://developer.apple.com/library/archive/documentation/TextFonts/Conceptual/CocoaTextArchitecture/Art/glyphterms_2x.png

I'll fix this by updating the major version.

@hajimehoshi
Copy link

hajimehoshi commented May 30, 2023

I expected that the center of the dot is (0, 0) from the comment

	// GlyphBounds returns the bounding box of r's glyph, drawn at a dot equal
	// to the origin, and that glyph's advance width.

but actually this was not with some TTF/OTF fonts (the minimum X was a positive value). This is quite confusing.

@mcarpenter622
Copy link
Collaborator

Fonts are overly complicated. I barely understand how they work. I'm very impressed with your bitmap library but glad I don't have to work on it lol.

@hajimehoshi
Copy link

@quasilyte Please try github.com/hajimehoshi/bitmapfont/v3 (v3.0.0-alpha). Thanks,

@quasilyte
Copy link
Contributor Author

quasilyte commented May 30, 2023

I can confirm that v3 solves all of my problems.
It makes the examples above with and without ebitenui work as expected.
It also makes the in-game text aligned properly.

I also tried the scaled bitmap version (Hajime would recognize this code, ahaha). It works correctly as well.

The multi-line text looks legit too.

@mcarpenter622 @hajimehoshi A big thanks for the fix and investigation. :)

@mcarpenter622 I'm using the bitmapfont because of its amazing multi-language support and great compatibility with pixel-art games. :) It's really neat. I think it will be useful in the upcoming Ebitengine game jam too!

@hajimehoshi
Copy link

I've created an issue about the comments: golang/go#60501

@mcarpenter622
Copy link
Collaborator

Thanks @hajimehoshi and @quasilyte for working through this together! I'm going to go ahead and close this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants