all: add text/v2

This change adds some basic APIs incuding StdFace.

GoTextFace will be added later.

Updates #2454
This commit is contained in:
Hajime Hoshi 2023-11-11 19:04:13 +09:00
parent 1d4c210ff2
commit b8b8b16098
9 changed files with 1016 additions and 79 deletions

View File

@ -17,7 +17,6 @@ package main
import ( import (
"bytes" "bytes"
"image" "image"
"image/color"
_ "image/png" _ "image/png"
"log" "log"
"strings" "strings"
@ -28,7 +27,7 @@ import (
"github.com/hajimehoshi/ebiten/v2/examples/keyboard/keyboard" "github.com/hajimehoshi/ebiten/v2/examples/keyboard/keyboard"
rkeyboard "github.com/hajimehoshi/ebiten/v2/examples/resources/images/keyboard" rkeyboard "github.com/hajimehoshi/ebiten/v2/examples/resources/images/keyboard"
"github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/text/v2"
) )
const ( const (
@ -36,6 +35,8 @@ const (
screenHeight = 240 screenHeight = 240
) )
var fontFace = text.NewStdFace(bitmapfont.Face)
var keyboardImage *ebiten.Image var keyboardImage *ebiten.Image
func init() { func init() {
@ -91,7 +92,9 @@ func (g *Game) Draw(screen *ebiten.Image) {
} }
// Use bitmapfont.Face instead of ebitenutil.DebugPrint, since some key names might not be printed with DebugPrint. // Use bitmapfont.Face instead of ebitenutil.DebugPrint, since some key names might not be printed with DebugPrint.
text.Draw(screen, strings.Join(keyStrs, ", ")+"\n"+strings.Join(keyNames, ", "), bitmapfont.Face, 4, 12, color.White) textOp := &text.DrawOptions{}
textOp.LineHeightInPixels = fontFace.Metrics().Height
text.Draw(screen, strings.Join(keyStrs, ", ")+"\n"+strings.Join(keyNames, ", "), fontFace, textOp)
} }
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {

View File

@ -26,7 +26,7 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
"github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
) )
@ -39,8 +39,8 @@ const sampleText = ` The quick brown fox jumps
over the lazy dog.` over the lazy dog.`
var ( var (
mplusNormalFont font.Face mplusNormalFace *text.StdFace
mplusBigFont font.Face mplusBigFace *text.StdFace
) )
func init() { func init() {
@ -50,7 +50,7 @@ func init() {
} }
const dpi = 72 const dpi = 72
mplusNormalFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ mplusNormalFont, err := opentype.NewFace(tt, &opentype.FaceOptions{
Size: 24, Size: 24,
DPI: dpi, DPI: dpi,
Hinting: font.HintingVertical, Hinting: font.HintingVertical,
@ -58,7 +58,9 @@ func init() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
mplusBigFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ mplusNormalFace = text.NewStdFace(mplusNormalFont)
mplusBigFont, err := opentype.NewFace(tt, &opentype.FaceOptions{
Size: 32, Size: 32,
DPI: dpi, DPI: dpi,
Hinting: font.HintingVertical, Hinting: font.HintingVertical,
@ -66,43 +68,24 @@ func init() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
mplusBigFace = text.NewStdFace(mplusBigFont)
} }
type Game struct { type Game struct {
counter int counter int
kanjiText []rune kanjiText []rune
kanjiTextColor color.RGBA kanjiTextColor color.RGBA
glyphs []text.Glyph glyphs [][]text.Glyph
} }
func (g *Game) Update() error { func (g *Game) Update() error {
// Initialize the glyphs for special (colorful) rendering. // Initialize the glyphs for special (colorful) rendering.
if len(g.glyphs) == 0 { if len(g.glyphs) == 0 {
g.glyphs = text.AppendGlyphs(g.glyphs, mplusNormalFont, sampleText) for _, line := range strings.Split(sampleText, "\n") {
} g.glyphs = append(g.glyphs, text.AppendGlyphs(nil, line, mplusNormalFace, 0, 0))
return nil
}
func boundString(face font.Face, str string) fixed.Rectangle26_6 {
str = strings.TrimRight(str, "\n")
lines := strings.Split(str, "\n")
if len(lines) == 0 {
return fixed.Rectangle26_6{}
}
minX := fixed.I(0)
maxX := fixed.I(0)
for _, line := range lines {
a := font.MeasureString(face, line)
if maxX < a {
maxX = a
} }
} }
return nil
m := face.Metrics()
minY := -m.Ascent
maxY := fixed.Int26_6(len(lines)-1)*m.Height + m.Descent
return fixed.Rectangle26_6{Min: fixed.Point26_6{X: minX, Y: minY}, Max: fixed.Point26_6{X: maxX, Y: maxY}}
} }
func fixed26_6ToFloat32(x fixed.Int26_6) float32 { func fixed26_6ToFloat32(x fixed.Int26_6) float32 {
@ -114,30 +97,40 @@ func (g *Game) Draw(screen *ebiten.Image) {
{ {
const x, y = 20, 40 const x, y = 20, 40
b := boundString(mplusNormalFont, sampleText) w, h := text.Measure(sampleText, mplusNormalFace, mplusNormalFace.Metrics().Height)
vector.DrawFilledRect(screen, fixed26_6ToFloat32(b.Min.X)+x, fixed26_6ToFloat32(b.Min.Y)+y, fixed26_6ToFloat32(b.Max.X-b.Min.X), fixed26_6ToFloat32(b.Max.Y-b.Min.Y), gray, false) vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false)
text.Draw(screen, sampleText, mplusNormalFont, x, y, color.White) op := &text.DrawOptions{}
op.GeoM.Translate(x, y)
op.LineHeightInPixels = mplusNormalFace.Metrics().Height
text.Draw(screen, sampleText, mplusNormalFace, op)
} }
{ {
const x, y = 20, 140 const x, y = 20, 140
b := boundString(mplusBigFont, sampleText) w, h := text.Measure(sampleText, mplusBigFace, mplusBigFace.Metrics().Height)
vector.DrawFilledRect(screen, fixed26_6ToFloat32(b.Min.X)+x, fixed26_6ToFloat32(b.Min.Y)+y, fixed26_6ToFloat32(b.Max.X-b.Min.X), fixed26_6ToFloat32(b.Max.Y-b.Min.Y), gray, false) vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false)
text.Draw(screen, sampleText, mplusBigFont, x, y, color.White) op := &text.DrawOptions{}
op.GeoM.Translate(x, y)
op.LineHeightInPixels = mplusBigFace.Metrics().Height
text.Draw(screen, sampleText, mplusBigFace, op)
} }
{ {
const x, y = 20, 240 const x, y = 20, 240
op := &ebiten.DrawImageOptions{} op := &text.DrawOptions{}
op.GeoM.Rotate(math.Pi / 4) op.GeoM.Rotate(math.Pi / 4)
op.GeoM.Translate(x, y) op.GeoM.Translate(x, y)
op.Filter = ebiten.FilterLinear op.Filter = ebiten.FilterLinear
text.DrawWithOptions(screen, sampleText, mplusNormalFont, op) op.LineHeightInPixels = mplusNormalFace.Metrics().Height
text.Draw(screen, sampleText, mplusNormalFace, op)
} }
{ {
const x, y = 160, 240 const x, y = 160, 240
const lineHeight = 80 const lineHeight = 80
b := boundString(text.FaceWithLineHeight(mplusBigFont, lineHeight), sampleText) w, h := text.Measure(sampleText, mplusBigFace, lineHeight)
vector.DrawFilledRect(screen, fixed26_6ToFloat32(b.Min.X)+x, fixed26_6ToFloat32(b.Min.Y)+y, fixed26_6ToFloat32(b.Max.X-b.Min.X), fixed26_6ToFloat32(b.Max.Y-b.Min.Y), gray, false) vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false)
text.Draw(screen, sampleText, text.FaceWithLineHeight(mplusBigFont, lineHeight), x, y, color.White) op := &text.DrawOptions{}
op.GeoM.Translate(x, y)
op.LineHeightInPixels = lineHeight
text.Draw(screen, sampleText, mplusBigFace, op)
} }
{ {
const x, y = 240, 400 const x, y = 240, 400
@ -145,25 +138,28 @@ func (g *Game) Draw(screen *ebiten.Image) {
// g.glyphs is initialized by text.AppendGlyphs. // g.glyphs is initialized by text.AppendGlyphs.
// You can customize how to render each glyph. // You can customize how to render each glyph.
// In this example, multiple colors are used to render glyphs. // In this example, multiple colors are used to render glyphs.
for i, gl := range g.glyphs { for j, line := range g.glyphs {
op.GeoM.Reset() for i, gl := range line {
op.GeoM.Translate(x, y) op.GeoM.Reset()
op.GeoM.Translate(gl.X, gl.Y) op.GeoM.Translate(x, y)
op.ColorScale.Reset() op.GeoM.Translate(0, float64(j)*mplusNormalFace.Metrics().Height)
r := float32(1) op.GeoM.Translate(gl.X, gl.Y)
if i%3 == 0 { op.ColorScale.Reset()
r = 0.5 r := float32(1)
if i%3 == 0 {
r = 0.5
}
g := float32(1)
if i%3 == 1 {
g = 0.5
}
b := float32(1)
if i%3 == 2 {
b = 0.5
}
op.ColorScale.Scale(r, g, b, 1)
screen.DrawImage(gl.Image, op)
} }
g := float32(1)
if i%3 == 1 {
g = 0.5
}
b := float32(1)
if i%3 == 2 {
b = 0.5
}
op.ColorScale.Scale(r, g, b, 1)
screen.DrawImage(gl.Image, op)
} }
} }
} }

View File

@ -24,17 +24,15 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/hajimehoshi/bitmapfont/v3" "github.com/hajimehoshi/bitmapfont/v3"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/exp/textinput" "github.com/hajimehoshi/ebiten/v2/exp/textinput"
"github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
) )
var fontFace = bitmapfont.FaceEA var fontFace = text.NewStdFace(bitmapfont.FaceEA)
const ( const (
screenWidth = 640 screenWidth = 640
@ -93,15 +91,15 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) {
y = 0 y = 0
} }
lineHeight := fontFace.Metrics().Height.Ceil() lineHeight := int(fontFace.Metrics().Height)
var nlCount int var nlCount int
var lineStart int var lineStart int
var prevAdvance fixed.Int26_6 var prevAdvance float64
for i, r := range t.text { for i, r := range t.text {
var x0, x1 int var x0, x1 int
currentAdvance := font.MeasureString(fontFace, t.text[lineStart:i]) currentAdvance := text.Advance(fontFace, t.text[lineStart:i])
if lineStart < i { if lineStart < i {
x0 = ((prevAdvance + currentAdvance) / 2).Ceil() x0 = int((prevAdvance + currentAdvance) / 2)
} }
if r == '\n' { if r == '\n' {
x1 = int(math.MaxInt32) x1 = int(math.MaxInt32)
@ -110,10 +108,10 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) {
for !utf8.ValidString(t.text[i:nextI]) { for !utf8.ValidString(t.text[i:nextI]) {
nextI++ nextI++
} }
nextAdvance := font.MeasureString(fontFace, t.text[lineStart:nextI]) nextAdvance := text.Advance(fontFace, t.text[lineStart:nextI])
x1 = ((currentAdvance + nextAdvance) / 2).Ceil() x1 = int((currentAdvance + nextAdvance) / 2)
} else { } else {
x1 = currentAdvance.Ceil() x1 = int(currentAdvance)
} }
if x0 <= x && x < x1 && nlCount*lineHeight <= y && y < (nlCount+1)*lineHeight { if x0 <= x && x < x1 && nlCount*lineHeight <= y && y < (nlCount+1)*lineHeight {
return i, true return i, true
@ -159,7 +157,7 @@ func (t *TextField) Update() {
cx, cy := t.cursorPos() cx, cy := t.cursorPos()
px, py := textFieldPadding() px, py := textFieldPadding()
x += cx + px x += cx + px
y += cy + py + fontFace.Metrics().Ascent.Ceil() y += cy + py + int(fontFace.Metrics().HAscent)
t.ch, t.end = textinput.Start(x, y) t.ch, t.end = textinput.Start(x, y)
// Start returns nil for non-supported envrionments. // Start returns nil for non-supported envrionments.
if t.ch == nil { if t.ch == nil {
@ -257,8 +255,8 @@ func (t *TextField) cursorPos() (int, int) {
if t.state.Text != "" { if t.state.Text != "" {
txt += t.state.Text[:t.state.CompositionSelectionStartInBytes] txt += t.state.Text[:t.state.CompositionSelectionStartInBytes]
} }
x := font.MeasureString(fontFace, txt).Ceil() x := int(text.Advance(fontFace, txt))
y := nlCount * fontFace.Metrics().Height.Ceil() y := nlCount * int(fontFace.Metrics().Height)
return x, y return x, y
} }
@ -276,7 +274,7 @@ func (t *TextField) Draw(screen *ebiten.Image) {
cx, cy := t.cursorPos() cx, cy := t.cursorPos()
x += px + cx x += px + cx
y += py + cy y += py + cy
h := fontFace.Metrics().Height.Ceil() h := int(fontFace.Metrics().Height)
vector.StrokeLine(screen, float32(x), float32(y), float32(x), float32(y+h), 1, color.Black, false) vector.StrokeLine(screen, float32(x), float32(y), float32(x), float32(y+h), 1, color.Black, false)
} }
@ -286,15 +284,19 @@ func (t *TextField) Draw(screen *ebiten.Image) {
} }
tx := t.bounds.Min.X + px tx := t.bounds.Min.X + px
ty := t.bounds.Min.Y + py + fontFace.Metrics().Ascent.Ceil() ty := t.bounds.Min.Y + py
text.Draw(screen, shownText, fontFace, tx, ty, color.Black) op := &text.DrawOptions{}
op.GeoM.Translate(float64(tx), float64(ty))
op.ColorScale.ScaleWithColor(color.Black)
op.LineHeightInPixels = fontFace.Metrics().Height
text.Draw(screen, shownText, fontFace, op)
} }
const textFieldHeight = 24 const textFieldHeight = 24
func textFieldPadding() (int, int) { func textFieldPadding() (int, int) {
m := fontFace.Metrics() m := fontFace.Metrics()
return 4, (textFieldHeight - m.Height.Ceil()) / 2 return 4, (textFieldHeight - int(m.Height)) / 2
} }
type Game struct { type Game struct {

View File

@ -15,6 +15,8 @@
// Package text offers functions to draw texts on an Ebitengine's image. // Package text offers functions to draw texts on an Ebitengine's image.
// //
// For the example using a TTF font, see font package in the examples. // For the example using a TTF font, see font package in the examples.
//
// Deprecated: as of v2.7. Use text/v2 instead.
package text package text
import ( import (

325
text/v2/draw.go Normal file
View File

@ -0,0 +1,325 @@
// Copyright 2023 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package text
import (
"strings"
"github.com/hajimehoshi/ebiten/v2"
)
// Align is the alignment that determines how to put a text.
type Align int
const (
AlignStart Align = iota
AlignCenter
AlignEnd
)
// DrawOptions represents options for the Draw function.
//
// DrawOption embeds ebiten.DrawImageOptions.
// DrawImageOptions.GeoM is an additional geometry transformation
// after putting the rendering region along with the specified alignments.
// DrawImageOptions.ColorScale scales the text color.
//
// PrimaryAlign and SecondaryAlign determine where to put the text in the given region at Draw.
// Draw might render the text outside of the specified image bounds, so you might have to specify GeoM to make the text visible.
type DrawOptions struct {
ebiten.DrawImageOptions
// LineHeightInPixels is a line height in pixels.
LineHeightInPixels float64
// PrimaryAlign is an alignment of the primary direction, in which a text in one line is rendered.
// The primary direction is the horizontal direction for a horizontal-direction face,
// and the vertical direction for a vertical-direction face.
// The meaning of the start and the end depends on the face direction.
PrimaryAlign Align
// SecondaryAlign is an alignment of the secondary direction, in which multiple lines are rendered.
// The secondary direction is the vertical direction for a horizontal-direction face,
// and the horizontal direction for a vertical-direction face.
// The meaning of the start and the end depends on the face direction.
SecondaryAlign Align
}
// Draw draws a given text on a given destination image dst.
// face is the font for text rendering.
//
// The '\n' newline character puts the following text on the next line.
//
// Glyphs used for rendering are cached in least-recently-used way.
// Then old glyphs might be evicted from the cache.
// As the cache capacity has limit, it is not guaranteed that all the glyphs for runes given at Draw are cached.
//
// It is OK to call Draw with a same text and a same face at every frame in terms of performance.
//
// Draw is concurrent-safe.
//
// # Rendering region
//
// A rectangle region where a text is put is called a 'rendering region'.
// The position of the text in the rendering region is determined by the specified primary and secondary alignments.
//
// The actual rendering position of the rendering region depends on the alignments in DrawOptions.
// By default, if the face's primary direction is left-to-right, the rendering region's upper-left position is (0, 0).
// Note that this is different from text v1. In text v1, (0, 0) is always the origin position.
//
// # Alignments
//
// For horizontal directions, the start and end depends on the face.
// If the face is GoTextFace, the start and the end depend on the Direction property.
// If the face is StdFace, the start and the end are always left and right respectively.
//
// For vertical directions, the start and end are top and bottom respectively.
//
// If the horizontal alignment is left, the rendering region's left X comes to the destination image's origin (0, 0).
// If the horizontal alignment is center, the rendering region's middle X comes to the origin.
// If the horizontal alignment is right, the rendering region's right X comes to the origin.
//
// If the vertical alignment is top, the rendering region's top Y comes to the destination image's origin (0, 0).
// If the vertical alignment is center, the rendering region's middle Y comes to the origin.
// If the vertical alignment is bottom, the rendering region's bottom Y comes to the origin.
func Draw(dst *ebiten.Image, text string, face Face, options *DrawOptions) {
if text == "" {
return
}
if options == nil {
options = &DrawOptions{}
}
// Calculate the advances for each line.
var advances []float64
var longestAdvance float64
var lineCount int
for t := text; ; {
lineCount++
line, rest, found := strings.Cut(t, "\n")
a := face.advance(line)
advances = append(advances, a)
if longestAdvance < a {
longestAdvance = a
}
if !found {
break
}
t = rest
}
d := face.direction()
m := face.Metrics()
var boundaryWidth, boundaryHeight float64
if d.isHorizontal() {
boundaryWidth = longestAdvance
boundaryHeight = float64(lineCount-1)*options.LineHeightInPixels + m.HAscent + m.HDescent
} else {
boundaryWidth = float64(lineCount-1)*options.LineHeightInPixels + m.VAscent + m.VDescent
boundaryHeight = longestAdvance
}
var offsetX, offsetY float64
// Whichever the direction and the alignments are, the Y position has an offset by an ascent for horizontal texts.
offsetY += m.HAscent
// Adjust the offset based on the secondary alignments.
h, v := calcAligns(d, options.PrimaryAlign, options.SecondaryAlign)
switch d {
case DirectionLeftToRight, DirectionRightToLeft:
switch v {
case verticalAlignTop:
case verticalAlignCenter:
offsetY -= boundaryHeight / 2
case verticalAlignBottom:
offsetY -= boundaryHeight
}
case DirectionTopToBottomAndLeftToRight:
offsetX -= m.VAscent
switch h {
case horizontalAlignLeft:
case horizontalAlignCenter:
offsetX -= boundaryWidth / 2
case horizontalAlignRight:
offsetX -= boundaryWidth
}
case DirectionTopToBottomAndRightToLeft:
offsetX -= m.VAscent
switch h {
case horizontalAlignLeft:
offsetX += boundaryWidth
case horizontalAlignCenter:
offsetX += boundaryWidth / 2
case horizontalAlignRight:
}
}
var originX, originY float64
var i int
geoM := options.GeoM
for t := text; ; {
line, rest, found := strings.Cut(t, "\n")
// Adjust the origin position based on the primary alignments.
switch d {
case DirectionLeftToRight, DirectionRightToLeft:
switch h {
case horizontalAlignLeft:
originX = 0
case horizontalAlignCenter:
originX = -advances[i] / 2
case horizontalAlignRight:
originX = -advances[i]
}
case DirectionTopToBottomAndLeftToRight, DirectionTopToBottomAndRightToLeft:
switch v {
case verticalAlignTop:
originY = 0
case verticalAlignCenter:
originY = -advances[i] / 2
case verticalAlignBottom:
originY = -advances[i]
}
}
drawLine(dst, line, face, options, originX+offsetX, originY+offsetY, geoM)
if !found {
break
}
t = rest
i++
// Advance the origin position in the secondary direction.
switch face.direction() {
case DirectionLeftToRight:
originY += options.LineHeightInPixels
case DirectionRightToLeft:
originY += options.LineHeightInPixels
case DirectionTopToBottomAndLeftToRight:
originX += options.LineHeightInPixels
case DirectionTopToBottomAndRightToLeft:
originX -= options.LineHeightInPixels
}
}
}
func drawLine(dst *ebiten.Image, line string, face Face, options *DrawOptions, originX, originY float64, geoM ebiten.GeoM) {
op := &options.DrawImageOptions
gs := face.appendGlyphs(nil, line, originX, originY)
for _, g := range gs {
op.GeoM.Reset()
op.GeoM.Translate(g.X, g.Y)
op.GeoM.Concat(geoM)
dst.DrawImage(g.Image, op)
}
}
type horizontalAlign int
const (
horizontalAlignLeft horizontalAlign = iota
horizontalAlignCenter
horizontalAlignRight
)
type verticalAlign int
const (
verticalAlignTop verticalAlign = iota
verticalAlignCenter
verticalAlignBottom
)
func calcAligns(direction Direction, primaryAlign, secondaryAlign Align) (horizontalAlign, verticalAlign) {
var h horizontalAlign
var v verticalAlign
switch direction {
case DirectionLeftToRight:
switch primaryAlign {
case AlignStart:
h = horizontalAlignLeft
case AlignCenter:
h = horizontalAlignCenter
case AlignEnd:
h = horizontalAlignRight
}
switch secondaryAlign {
case AlignStart:
v = verticalAlignTop
case AlignCenter:
v = verticalAlignCenter
case AlignEnd:
v = verticalAlignBottom
}
case DirectionRightToLeft:
switch primaryAlign {
case AlignStart:
h = horizontalAlignRight
case AlignCenter:
h = horizontalAlignCenter
case AlignEnd:
h = horizontalAlignLeft
}
switch secondaryAlign {
case AlignStart:
v = verticalAlignTop
case AlignCenter:
v = verticalAlignCenter
case AlignEnd:
v = verticalAlignBottom
}
case DirectionTopToBottomAndLeftToRight:
switch primaryAlign {
case AlignStart:
v = verticalAlignTop
case AlignCenter:
v = verticalAlignCenter
case AlignEnd:
v = verticalAlignBottom
}
switch secondaryAlign {
case AlignStart:
h = horizontalAlignLeft
case AlignCenter:
h = horizontalAlignCenter
case AlignEnd:
h = horizontalAlignRight
}
case DirectionTopToBottomAndRightToLeft:
switch primaryAlign {
case AlignStart:
v = verticalAlignTop
case AlignCenter:
v = verticalAlignCenter
case AlignEnd:
v = verticalAlignBottom
}
switch secondaryAlign {
case AlignStart:
h = horizontalAlignRight
case AlignCenter:
h = horizontalAlignCenter
case AlignEnd:
h = horizontalAlignLeft
}
}
return h, v
}

117
text/v2/glyph.go Normal file
View File

@ -0,0 +1,117 @@
// Copyright 2023 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package text
import (
"math"
"runtime"
"sync"
"golang.org/x/image/math/fixed"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/internal/hook"
)
var monotonicClock int64
const infTime = math.MaxInt64
func now() int64 {
return monotonicClock
}
func init() {
hook.AppendHookOnBeforeUpdate(func() error {
monotonicClock++
return nil
})
}
type glyphImageCacheKey struct {
// For StdFace
rune rune
xoffset fixed.Int26_6
}
type glyphImageCacheEntry struct {
image *ebiten.Image
atime int64
}
type glyphImageCache struct {
cache map[Face]map[glyphImageCacheKey]*glyphImageCacheEntry
m sync.Mutex
}
var theGlyphImageCache glyphImageCache
func (g *glyphImageCache) getOrCreate(face Face, key glyphImageCacheKey, create func() *ebiten.Image) *ebiten.Image {
g.m.Lock()
defer g.m.Unlock()
e, ok := g.cache[face][key]
if ok {
e.atime = now()
return e.image
}
if g.cache == nil {
g.cache = map[Face]map[glyphImageCacheKey]*glyphImageCacheEntry{}
}
if g.cache[face] == nil {
g.cache[face] = map[glyphImageCacheKey]*glyphImageCacheEntry{}
}
img := create()
e = &glyphImageCacheEntry{
image: img,
}
if img != nil {
e.atime = now()
} else {
// If the glyph image is nil, the entry doesn't have to be removed.
// Keep this until the face is GCed.
e.atime = infTime
}
g.cache[face][key] = e
// Clean up old entries.
// cacheSoftLimit indicates the soft limit of the number of glyphs in the cache.
// If the number of glyphs exceeds this soft limits, old glyphs are removed.
// Even after cleaning up the cache, the number of glyphs might still exceed the soft limit, but
// this is fine.
const cacheSoftLimit = 512
if len(g.cache[face]) > cacheSoftLimit {
for key, e := range g.cache[face] {
// 60 is an arbitrary number.
if e.atime >= now()-60 {
continue
}
delete(g.cache[face], key)
}
}
return img
}
func (g *glyphImageCache) clear(face Face) {
runtime.SetFinalizer(face, nil)
g.m.Lock()
defer g.m.Unlock()
delete(g.cache, face)
}

166
text/v2/std.go Normal file
View File

@ -0,0 +1,166 @@
// Copyright 2023 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package text
import (
"image"
"runtime"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
"github.com/hajimehoshi/ebiten/v2"
)
var _ Face = (*StdFace)(nil)
// StdFace is a Face implementation for a semi-standard font.Face (golang.org/x/image/font).
// StdFace is useful to transit from existing codebase with text v1, or to use some bitmap fonts defined as font.Face.
// StdFace must not be copied by value.
type StdFace struct {
f *faceWithCache
addr *StdFace
}
// NewStdFace creates a new StdFace from a semi-standard font.Face.
func NewStdFace(face font.Face) *StdFace {
s := &StdFace{
f: &faceWithCache{
f: face,
},
}
s.addr = s
runtime.SetFinalizer(s, theGlyphImageCache.clear)
return s
}
func (s *StdFace) copyCheck() {
if s.addr != s {
panic("text: illegal use of non-zero StdFace copied by value")
}
}
// Metrics implelements Face.
func (s *StdFace) Metrics() Metrics {
s.copyCheck()
m := s.f.Metrics()
return Metrics{
Height: fixed26_6ToFloat64(m.Height),
HAscent: fixed26_6ToFloat64(m.Ascent),
HDescent: fixed26_6ToFloat64(m.Descent),
}
}
// UnsafeInternal implements Face.
func (s *StdFace) UnsafeInternal() any {
s.copyCheck()
return s.f.f
}
// advance implements Face.
func (s *StdFace) advance(text string) float64 {
return fixed26_6ToFloat64(font.MeasureString(s.f, text))
}
// appendGlyphs implements Face.
func (s *StdFace) appendGlyphs(glyphs []Glyph, text string, originX, originY float64) []Glyph {
s.copyCheck()
x := float64ToFixed26_6(originX)
y := float64ToFixed26_6(originY)
prevR := rune(-1)
for i, r := range text {
if prevR >= 0 {
x += s.f.Kern(prevR, r)
}
img, imgX, imgY, a := s.glyphImage(r, x, y)
if img != nil {
// Adjust the position to the integers.
// The current glyph images assume that they are rendered on integer positions so far.
glyphs = append(glyphs, Glyph{
Rune: r,
IndexInBytes: i,
Image: img,
X: imgX,
Y: imgY,
})
}
x += a
prevR = r
}
return glyphs
}
func (s *StdFace) glyphImage(r rune, x, y fixed.Int26_6) (*ebiten.Image, float64, float64, fixed.Int26_6) {
b, a, _ := s.f.GlyphBounds(r)
offset := fixed.Point26_6{
X: (adjustOffsetGranularity(x) + b.Min.X) & ((1 << 6) - 1),
Y: (fixed.I(y.Floor()) + b.Min.Y) & ((1 << 6) - 1),
}
key := glyphImageCacheKey{
rune: r,
xoffset: offset.X,
// yoffset is always an integer, so this doesn't have to be a key.
}
img := theGlyphImageCache.getOrCreate(s, key, func() *ebiten.Image {
return s.glyphImageImpl(r, offset)
})
imgX := fixed26_6ToFloat64(x + b.Min.X - offset.X)
imgY := fixed26_6ToFloat64(y + b.Min.Y - offset.Y)
return img, imgX, imgY, a
}
func (s *StdFace) glyphImageImpl(r rune, offset fixed.Point26_6) *ebiten.Image {
b, _, _ := s.f.GlyphBounds(r)
w, h := (b.Max.X - b.Min.X).Ceil(), (b.Max.Y - b.Min.Y).Ceil()
if w == 0 || h == 0 {
return nil
}
if b.Min.X&((1<<6)-1) != 0 {
w++
}
if b.Min.Y&((1<<6)-1) != 0 {
h++
}
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
d := font.Drawer{
Dst: rgba,
Src: image.White,
Face: s.f,
}
x, y := -b.Min.X, -b.Min.Y
x += offset.X
y += offset.Y
d.Dot = fixed.Point26_6{X: x, Y: y}
d.DrawString(string(r))
return ebiten.NewImageFromImage(rgba)
}
// direction implelements Face.
func (s *StdFace) direction() Direction {
return DirectionLeftToRight
}
// Metrics implelements Face.
func (s *StdFace) private() {
}

127
text/v2/stdcache.go Normal file
View File

@ -0,0 +1,127 @@
// Copyright 2023 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package text
import (
"image"
"sync"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)
type glyphBoundsCacheValue struct {
bounds fixed.Rectangle26_6
advance fixed.Int26_6
ok bool
}
type glyphAdvanceCacheValue struct {
advance fixed.Int26_6
ok bool
}
type kernCacheKey struct {
r0 rune
r1 rune
}
type faceWithCache struct {
f font.Face
glyphBoundsCache map[rune]glyphBoundsCacheValue
glyphAdvanceCache map[rune]glyphAdvanceCacheValue
kernCache map[kernCacheKey]fixed.Int26_6
m sync.Mutex
}
func (f *faceWithCache) Close() error {
if err := f.f.Close(); err != nil {
return err
}
f.m.Lock()
defer f.m.Unlock()
f.glyphBoundsCache = nil
f.glyphAdvanceCache = nil
f.kernCache = nil
return nil
}
func (f *faceWithCache) Glyph(dot fixed.Point26_6, r rune) (dr image.Rectangle, mask image.Image, maskp image.Point, advance fixed.Int26_6, ok bool) {
return f.f.Glyph(dot, r)
}
func (f *faceWithCache) GlyphBounds(r rune) (bounds fixed.Rectangle26_6, advance fixed.Int26_6, ok bool) {
f.m.Lock()
defer f.m.Unlock()
if v, ok := f.glyphBoundsCache[r]; ok {
return v.bounds, v.advance, v.ok
}
bounds, advance, ok = f.f.GlyphBounds(r)
if f.glyphBoundsCache == nil {
f.glyphBoundsCache = map[rune]glyphBoundsCacheValue{}
}
f.glyphBoundsCache[r] = glyphBoundsCacheValue{
bounds: bounds,
advance: advance,
ok: ok,
}
return
}
func (f *faceWithCache) GlyphAdvance(r rune) (advance fixed.Int26_6, ok bool) {
f.m.Lock()
defer f.m.Unlock()
if v, ok := f.glyphAdvanceCache[r]; ok {
return v.advance, v.ok
}
advance, ok = f.f.GlyphAdvance(r)
if f.glyphAdvanceCache == nil {
f.glyphAdvanceCache = map[rune]glyphAdvanceCacheValue{}
}
f.glyphAdvanceCache[r] = glyphAdvanceCacheValue{
advance: advance,
ok: ok,
}
return
}
func (f *faceWithCache) Kern(r0, r1 rune) fixed.Int26_6 {
f.m.Lock()
defer f.m.Unlock()
key := kernCacheKey{r0: r0, r1: r1}
if v, ok := f.kernCache[key]; ok {
return v
}
v := f.f.Kern(r0, r1)
if f.kernCache == nil {
f.kernCache = map[kernCacheKey]fixed.Int26_6{}
}
f.kernCache[key] = v
return v
}
func (f *faceWithCache) Metrics() font.Metrics {
return f.f.Metrics()
}

199
text/v2/text.go Normal file
View File

@ -0,0 +1,199 @@
// Copyright 2023 The Ebitengine Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package text offers functions to draw texts on an Ebitengine's image.
//
// For the example using a TrueType font, see examples in the examples directory.
package text
import (
"math"
"strings"
"golang.org/x/image/math/fixed"
"github.com/hajimehoshi/ebiten/v2"
)
// Face is an interface representing a font face. The implementations are only GoTextFace and StdFace.
type Face interface {
// Metrics returns the metrics for this Face.
Metrics() Metrics
// UnsafeInternal returns the internal object for this face.
// The returned value is either a semi-standard font.Face or go-text's font.Face.
// This is unsafe since this might make internal cache states out of sync.
UnsafeInternal() any
advance(text string) float64
appendGlyphs(glyphs []Glyph, text string, originX, originY float64) []Glyph
direction() Direction
// private is an unexported function preventing being implemented by other packages.
private()
}
// Metrics holds the metrics for a Face.
// A visual depiction is at https://developer.apple.com/library/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/Art/glyph_metrics_2x.png
type Metrics struct {
// Height is the recommended amount of vertical space between two lines of text in pixels.
Height float64
// HAscent is the distance in pixels from the top of a line to its baseline for horizontal lines.
HAscent float64
// HDescent is the distance in pixels from the bottom of a line to its baseline for horizontal lines.
// The value is typically positive, even though a descender goes below the baseline.
HDescent float64
// VAscent is the distance in pixels from the top of a line to its baseline for vertical lines.
// If the face is StdFace or the font dosen't support a vertical direction, VAscent is 0.
VAscent float64
// VDescent is the distance in pixels from the top of a line to its baseline for vertical lines.
// If the face is StdFace or the font dosen't support a vertical direction, VDescent is 0.
VDescent float64
}
func fixed26_6ToFloat64(x fixed.Int26_6) float64 {
return float64(x>>6) + float64(x&((1<<6)-1))/float64(1<<6)
}
func float64ToFixed26_6(x float64) fixed.Int26_6 {
i := math.Floor(x)
frac := x - i
return fixed.Int26_6(i)<<6 + fixed.Int26_6(frac*(1<<6))
}
func adjustOffsetGranularity(x fixed.Int26_6) fixed.Int26_6 {
return x / (1 << 4) * (1 << 4)
}
// Glyph represents one glyph to render.
type Glyph struct {
// Rune is a character for this glyph.
Rune rune
// IndexInBytes is an index in bytes for the given string at AppendGlyphs.
IndexInBytes int
// Image is a rasterized glyph image.
// Image is a grayscale image i.e. RGBA values are the same.
// Image should be used as a render source and should not be modified.
Image *ebiten.Image
// X is the X position to render this glyph.
// The position is determined in a sequence of characters given at AppendGlyphs.
// The position's origin is the first character's origin position.
X float64
// Y is the Y position to render this glyph.
// The position is determined in a sequence of characters given at AppendGlyphs.
// The position's origin is the first character's origin position.
Y float64
// GID is an ID for a glyph of TrueType or OpenType font. GID is valid when the font is GoTextFont.
GID uint32
}
// AppendGlyphs appends glyphs to the given slice and returns a slice.
//
// AppendGlyphs is a low-level API, and you can use AppendGlyphs to have more control than Draw.
// AppendGlyphs is also available to precache glyphs.
//
// AppendGlyphs doesn't treat multiple lines.
//
// AppendGlyphs is concurrent-safe.
func AppendGlyphs(glyphs []Glyph, text string, face Face, originX, originY float64) []Glyph {
return face.appendGlyphs(glyphs, text, originX, originY)
}
// Advance returns the advanced distance from the origin position when rendering the given text with the given face.
//
// Advance doesn't treat multiple lines.
//
// Advance is concurrent-safe.
func Advance(face Face, text string) float64 {
return face.advance(text)
}
// Direction represents a direction of text rendering.
// Direction indicates both the primary direction, in which a text in one line is rendered,
// and the secondary direction, in which multiple lines are rendered.
type Direction int
const (
// DirectionLeftToRight indicates that the primary direction is from left to right,
// and the secondary direction is from top to bottom.
DirectionLeftToRight Direction = iota
// DirectionRightToLeft indicates that the primary direction is from right to left,
// and the secondary direction is from top to bottom.
DirectionRightToLeft
// DirectionTopToBottomAndLeftToRight indicates that the primary direction is from top to bottom,
// and the secondary direction is from left to right.
// This is used e.g. for Mongolian.
DirectionTopToBottomAndLeftToRight
// DirectionTopToBottomAndRightToLeft indicates that the primary direction is from top to bottom,
// and the secondary direction is from right to left.
// This is used e.g. for Japanese.
DirectionTopToBottomAndRightToLeft
)
func (d Direction) isHorizontal() bool {
switch d {
case DirectionLeftToRight, DirectionRightToLeft:
return true
}
return false
}
// Measure measures the boundary size of the text.
// With a horizontal direction face, the width is the longest line's advance, and the height is the total of line heights.
// With a vertical direction face, the width and the height are calculated in an opposite manner.
//
// Measure is concurrent-safe.
func Measure(text string, face Face, lineHeightInPixels float64) (width, height float64) {
if text == "" {
return 0, 0
}
var primary float64
var lineCount int
for t := text; ; {
lineCount++
line, rest, found := strings.Cut(t, "\n")
a := face.advance(line)
if primary < a {
primary = a
}
if !found {
break
}
t = rest
}
m := face.Metrics()
if face.direction().isHorizontal() {
secondary := float64(lineCount-1)*lineHeightInPixels + m.HAscent + m.HDescent
return primary, secondary
}
secondary := float64(lineCount-1)*lineHeightInPixels + m.VAscent + m.VDescent
return secondary, primary
}