From b8b8b1609873419d3573d50caf8398d9bbfb540b Mon Sep 17 00:00:00 2001 From: Hajime Hoshi Date: Sat, 11 Nov 2023 19:04:13 +0900 Subject: [PATCH] all: add text/v2 This change adds some basic APIs incuding StdFace. GoTextFace will be added later. Updates #2454 --- examples/keyboard/main.go | 9 +- examples/text/main.go | 112 ++++++------- examples/textinput/main.go | 38 +++-- text/text.go | 2 + text/v2/draw.go | 325 +++++++++++++++++++++++++++++++++++++ text/v2/glyph.go | 117 +++++++++++++ text/v2/std.go | 166 +++++++++++++++++++ text/v2/stdcache.go | 127 +++++++++++++++ text/v2/text.go | 199 +++++++++++++++++++++++ 9 files changed, 1016 insertions(+), 79 deletions(-) create mode 100644 text/v2/draw.go create mode 100644 text/v2/glyph.go create mode 100644 text/v2/std.go create mode 100644 text/v2/stdcache.go create mode 100644 text/v2/text.go diff --git a/examples/keyboard/main.go b/examples/keyboard/main.go index 67f51bc5e..aaccd0f65 100644 --- a/examples/keyboard/main.go +++ b/examples/keyboard/main.go @@ -17,7 +17,6 @@ package main import ( "bytes" "image" - "image/color" _ "image/png" "log" "strings" @@ -28,7 +27,7 @@ import ( "github.com/hajimehoshi/ebiten/v2/examples/keyboard/keyboard" rkeyboard "github.com/hajimehoshi/ebiten/v2/examples/resources/images/keyboard" "github.com/hajimehoshi/ebiten/v2/inpututil" - "github.com/hajimehoshi/ebiten/v2/text" + "github.com/hajimehoshi/ebiten/v2/text/v2" ) const ( @@ -36,6 +35,8 @@ const ( screenHeight = 240 ) +var fontFace = text.NewStdFace(bitmapfont.Face) + var keyboardImage *ebiten.Image 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. - 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) { diff --git a/examples/text/main.go b/examples/text/main.go index 68505cae6..186eaad87 100644 --- a/examples/text/main.go +++ b/examples/text/main.go @@ -26,7 +26,7 @@ import ( "github.com/hajimehoshi/ebiten/v2" "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" ) @@ -39,8 +39,8 @@ const sampleText = ` The quick brown fox jumps over the lazy dog.` var ( - mplusNormalFont font.Face - mplusBigFont font.Face + mplusNormalFace *text.StdFace + mplusBigFace *text.StdFace ) func init() { @@ -50,7 +50,7 @@ func init() { } const dpi = 72 - mplusNormalFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ + mplusNormalFont, err := opentype.NewFace(tt, &opentype.FaceOptions{ Size: 24, DPI: dpi, Hinting: font.HintingVertical, @@ -58,7 +58,9 @@ func init() { if err != nil { log.Fatal(err) } - mplusBigFont, err = opentype.NewFace(tt, &opentype.FaceOptions{ + mplusNormalFace = text.NewStdFace(mplusNormalFont) + + mplusBigFont, err := opentype.NewFace(tt, &opentype.FaceOptions{ Size: 32, DPI: dpi, Hinting: font.HintingVertical, @@ -66,43 +68,24 @@ func init() { if err != nil { log.Fatal(err) } + mplusBigFace = text.NewStdFace(mplusBigFont) } type Game struct { counter int kanjiText []rune kanjiTextColor color.RGBA - glyphs []text.Glyph + glyphs [][]text.Glyph } func (g *Game) Update() error { // Initialize the glyphs for special (colorful) rendering. if len(g.glyphs) == 0 { - g.glyphs = text.AppendGlyphs(g.glyphs, mplusNormalFont, sampleText) - } - 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 + for _, line := range strings.Split(sampleText, "\n") { + g.glyphs = append(g.glyphs, text.AppendGlyphs(nil, line, mplusNormalFace, 0, 0)) } } - - 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}} + return nil } func fixed26_6ToFloat32(x fixed.Int26_6) float32 { @@ -114,30 +97,40 @@ func (g *Game) Draw(screen *ebiten.Image) { { const x, y = 20, 40 - b := boundString(mplusNormalFont, sampleText) - 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) - text.Draw(screen, sampleText, mplusNormalFont, x, y, color.White) + w, h := text.Measure(sampleText, mplusNormalFace, mplusNormalFace.Metrics().Height) + vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false) + op := &text.DrawOptions{} + op.GeoM.Translate(x, y) + op.LineHeightInPixels = mplusNormalFace.Metrics().Height + text.Draw(screen, sampleText, mplusNormalFace, op) } { const x, y = 20, 140 - b := boundString(mplusBigFont, sampleText) - 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) - text.Draw(screen, sampleText, mplusBigFont, x, y, color.White) + w, h := text.Measure(sampleText, mplusBigFace, mplusBigFace.Metrics().Height) + vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false) + op := &text.DrawOptions{} + op.GeoM.Translate(x, y) + op.LineHeightInPixels = mplusBigFace.Metrics().Height + text.Draw(screen, sampleText, mplusBigFace, op) } { const x, y = 20, 240 - op := &ebiten.DrawImageOptions{} + op := &text.DrawOptions{} op.GeoM.Rotate(math.Pi / 4) op.GeoM.Translate(x, y) 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 lineHeight = 80 - b := boundString(text.FaceWithLineHeight(mplusBigFont, lineHeight), sampleText) - 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) - text.Draw(screen, sampleText, text.FaceWithLineHeight(mplusBigFont, lineHeight), x, y, color.White) + w, h := text.Measure(sampleText, mplusBigFace, lineHeight) + vector.DrawFilledRect(screen, x, y, float32(w), float32(h), gray, false) + op := &text.DrawOptions{} + op.GeoM.Translate(x, y) + op.LineHeightInPixels = lineHeight + text.Draw(screen, sampleText, mplusBigFace, op) } { const x, y = 240, 400 @@ -145,25 +138,28 @@ func (g *Game) Draw(screen *ebiten.Image) { // g.glyphs is initialized by text.AppendGlyphs. // You can customize how to render each glyph. // In this example, multiple colors are used to render glyphs. - for i, gl := range g.glyphs { - op.GeoM.Reset() - op.GeoM.Translate(x, y) - op.GeoM.Translate(gl.X, gl.Y) - op.ColorScale.Reset() - r := float32(1) - if i%3 == 0 { - r = 0.5 + for j, line := range g.glyphs { + for i, gl := range line { + op.GeoM.Reset() + op.GeoM.Translate(x, y) + op.GeoM.Translate(0, float64(j)*mplusNormalFace.Metrics().Height) + op.GeoM.Translate(gl.X, gl.Y) + op.ColorScale.Reset() + 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) } } } diff --git a/examples/textinput/main.go b/examples/textinput/main.go index 923d92271..8a5e5ba8f 100644 --- a/examples/textinput/main.go +++ b/examples/textinput/main.go @@ -24,17 +24,15 @@ import ( "unicode/utf8" "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/exp/textinput" "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" ) -var fontFace = bitmapfont.FaceEA +var fontFace = text.NewStdFace(bitmapfont.FaceEA) const ( screenWidth = 640 @@ -93,15 +91,15 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { y = 0 } - lineHeight := fontFace.Metrics().Height.Ceil() + lineHeight := int(fontFace.Metrics().Height) var nlCount int var lineStart int - var prevAdvance fixed.Int26_6 + var prevAdvance float64 for i, r := range t.text { var x0, x1 int - currentAdvance := font.MeasureString(fontFace, t.text[lineStart:i]) + currentAdvance := text.Advance(fontFace, t.text[lineStart:i]) if lineStart < i { - x0 = ((prevAdvance + currentAdvance) / 2).Ceil() + x0 = int((prevAdvance + currentAdvance) / 2) } if r == '\n' { x1 = int(math.MaxInt32) @@ -110,10 +108,10 @@ func (t *TextField) textIndexByCursorPosition(x, y int) (int, bool) { for !utf8.ValidString(t.text[i:nextI]) { nextI++ } - nextAdvance := font.MeasureString(fontFace, t.text[lineStart:nextI]) - x1 = ((currentAdvance + nextAdvance) / 2).Ceil() + nextAdvance := text.Advance(fontFace, t.text[lineStart:nextI]) + x1 = int((currentAdvance + nextAdvance) / 2) } else { - x1 = currentAdvance.Ceil() + x1 = int(currentAdvance) } if x0 <= x && x < x1 && nlCount*lineHeight <= y && y < (nlCount+1)*lineHeight { return i, true @@ -159,7 +157,7 @@ func (t *TextField) Update() { cx, cy := t.cursorPos() px, py := textFieldPadding() 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) // Start returns nil for non-supported envrionments. if t.ch == nil { @@ -257,8 +255,8 @@ func (t *TextField) cursorPos() (int, int) { if t.state.Text != "" { txt += t.state.Text[:t.state.CompositionSelectionStartInBytes] } - x := font.MeasureString(fontFace, txt).Ceil() - y := nlCount * fontFace.Metrics().Height.Ceil() + x := int(text.Advance(fontFace, txt)) + y := nlCount * int(fontFace.Metrics().Height) return x, y } @@ -276,7 +274,7 @@ func (t *TextField) Draw(screen *ebiten.Image) { cx, cy := t.cursorPos() x += px + cx 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) } @@ -286,15 +284,19 @@ func (t *TextField) Draw(screen *ebiten.Image) { } tx := t.bounds.Min.X + px - ty := t.bounds.Min.Y + py + fontFace.Metrics().Ascent.Ceil() - text.Draw(screen, shownText, fontFace, tx, ty, color.Black) + ty := t.bounds.Min.Y + py + 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 func textFieldPadding() (int, int) { m := fontFace.Metrics() - return 4, (textFieldHeight - m.Height.Ceil()) / 2 + return 4, (textFieldHeight - int(m.Height)) / 2 } type Game struct { diff --git a/text/text.go b/text/text.go index 3e6acd735..140ee5d6d 100644 --- a/text/text.go +++ b/text/text.go @@ -15,6 +15,8 @@ // 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. +// +// Deprecated: as of v2.7. Use text/v2 instead. package text import ( diff --git a/text/v2/draw.go b/text/v2/draw.go new file mode 100644 index 000000000..c10a8d1f2 --- /dev/null +++ b/text/v2/draw.go @@ -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 +} diff --git a/text/v2/glyph.go b/text/v2/glyph.go new file mode 100644 index 000000000..620851126 --- /dev/null +++ b/text/v2/glyph.go @@ -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) +} diff --git a/text/v2/std.go b/text/v2/std.go new file mode 100644 index 000000000..23fcde0ed --- /dev/null +++ b/text/v2/std.go @@ -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() { +} diff --git a/text/v2/stdcache.go b/text/v2/stdcache.go new file mode 100644 index 000000000..b871b78c0 --- /dev/null +++ b/text/v2/stdcache.go @@ -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() +} diff --git a/text/v2/text.go b/text/v2/text.go new file mode 100644 index 000000000..cc06d2c75 --- /dev/null +++ b/text/v2/text.go @@ -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 +}