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

Some text improvements including falling back to system fonts #4721

Merged
merged 17 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
184 changes: 138 additions & 46 deletions internal/painter/font.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ import (
"github.com/go-text/render"
"github.com/go-text/typesetting/di"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/fontscan"
"github.com/go-text/typesetting/language"
"github.com/go-text/typesetting/opentype/api/metadata"
"github.com/go-text/typesetting/shaping"
"golang.org/x/image/math/fixed"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/internal/cache"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme"
)

Expand All @@ -26,40 +30,103 @@ const (
fontTabSpaceSize = 10
)

var (
fm *fontscan.FontMap
mapLock = sync.Mutex{}
load sync.Once
)

func loadMap() {
fm = fontscan.NewFontMap(noopLogger{})
err := loadSystemFonts(fm)
if err != nil {
fm = nil // just don't fallback
}
}

func lookupLangFont(family string, aspect metadata.Aspect) font.Face {
mapLock.Lock()
defer mapLock.Unlock()
load.Do(loadMap)
if fm == nil {
return nil
}

fm.SetQuery(fontscan.Query{Families: []string{family}, Aspect: aspect})
l, _ := fontscan.NewLangID(language.Language(lang.SystemLocale().LanguageString()))
return fm.ResolveFaceForLang(l)
}

func lookupRuneFont(r rune, family string, aspect metadata.Aspect) font.Face {
mapLock.Lock()
defer mapLock.Unlock()
load.Do(loadMap)
if fm == nil {
return nil
}

fm.SetQuery(fontscan.Query{Families: []string{family}, Aspect: aspect})
return fm.ResolveFace(r)
}

func lookupFaces(theme, fallback fyne.Resource, family string, style fyne.TextStyle) (faces *dynamicFontMap) {
f1 := loadMeasureFont(theme)
if theme == fallback {
faces = &dynamicFontMap{family: family, faces: []font.Face{f1}}
} else {
f2 := loadMeasureFont(fallback)
faces = &dynamicFontMap{family: family, faces: []font.Face{f1, f2}}
}

aspect := metadata.Aspect{Style: metadata.StyleNormal}
if style.Italic {
aspect.Style = metadata.StyleItalic
}
if style.Bold {
aspect.Weight = metadata.WeightBold
}

local := lookupLangFont(family, aspect)
if local != nil {
faces.addFace(local)
}

return faces
}

// CachedFontFace returns a Font face held in memory. These are loaded from the current theme.
func CachedFontFace(style fyne.TextStyle, fontDP float32, texScale float32) *FontCacheItem {
val, ok := fontCache.Load(style)
if !ok {
var f1, f2 font.Face
var faces *dynamicFontMap
switch {
case style.Monospace:
f1 = loadMeasureFont(theme.TextMonospaceFont())
f2 = loadMeasureFont(theme.DefaultTextMonospaceFont())
faces = lookupFaces(theme.TextMonospaceFont(), theme.DefaultTextMonospaceFont(), fontscan.Monospace, style)
case style.Bold:
if style.Italic {
f1 = loadMeasureFont(theme.TextBoldItalicFont())
f2 = loadMeasureFont(theme.DefaultTextBoldItalicFont())
faces = lookupFaces(theme.TextBoldItalicFont(), theme.DefaultTextBoldItalicFont(), fontscan.SansSerif, style)
} else {
f1 = loadMeasureFont(theme.TextBoldFont())
f2 = loadMeasureFont(theme.DefaultTextBoldFont())
faces = lookupFaces(theme.TextBoldFont(), theme.DefaultTextBoldFont(), fontscan.SansSerif, style)
}
case style.Italic:
f1 = loadMeasureFont(theme.TextItalicFont())
f2 = loadMeasureFont(theme.DefaultTextItalicFont())
faces = lookupFaces(theme.TextItalicFont(), theme.DefaultTextItalicFont(), fontscan.SansSerif, style)
case style.Symbol:
f1 = loadMeasureFont(theme.SymbolFont())
f2 = loadMeasureFont(theme.DefaultSymbolFont())
th := theme.SymbolFont()
fallback := theme.DefaultSymbolFont()
f1 := loadMeasureFont(th)

if th == fallback {
faces = &dynamicFontMap{family: fontscan.SansSerif, faces: []font.Face{f1}}
} else {
f2 := loadMeasureFont(fallback)
faces = &dynamicFontMap{family: fontscan.SansSerif, faces: []font.Face{f1, f2}}
}
default:
f1 = loadMeasureFont(theme.TextFont())
f2 = loadMeasureFont(theme.DefaultTextFont())
faces = lookupFaces(theme.TextFont(), theme.DefaultTextFont(), fontscan.SansSerif, style)
}

if f1 == nil {
f1 = f2
}
faces := []font.Face{f1, f2}
if emoji := theme.DefaultEmojiFont(); emoji != nil {
faces = append(faces, loadMeasureFont(emoji))
if emoji := theme.DefaultEmojiFont(); !style.Symbol && emoji != nil {
faces.addFace(loadMeasureFont(emoji)) // TODO only one emoji - maybe others too
}
val = &FontCacheItem{Fonts: faces}
fontCache.Store(style, val)
Expand All @@ -75,29 +142,22 @@ func ClearFontCache() {
}

// DrawString draws a string into an image.
func DrawString(dst draw.Image, s string, color color.Color, f []font.Face, fontSize, scale float32, tabWidth int) {
func DrawString(dst draw.Image, s string, color color.Color, f shaping.Fontmap, fontSize, scale float32, style fyne.TextStyle) {
r := render.Renderer{
FontSize: fontSize,
PixScale: scale,
Color: color,
}

// TODO avoid shaping twice!
sh := &shaping.HarfbuzzShaper{}
out := sh.Shape(shaping.Input{
Text: []rune(s),
RunStart: 0,
RunEnd: len(s),
Face: f[0],
Size: fixed.I(int(fontSize * r.PixScale)),
})

advance := float32(0)
y := int(math.Ceil(float64(fixed266ToFloat32(out.LineBounds.Ascent))))
walkString(f, s, float32ToFixed266(fontSize), tabWidth, &advance, scale, func(run shaping.Output, x float32) {
y := math.MinInt
walkString(f, s, float32ToFixed266(fontSize), style, &advance, scale, func(run shaping.Output, x float32) {
if y == math.MinInt {
y = int(math.Ceil(float64(fixed266ToFloat32(run.LineBounds.Ascent) * r.PixScale)))
}
if len(run.Glyphs) == 1 {
if run.Glyphs[0].GlyphID == 0 {
r.DrawStringAt(string([]rune{0xfffd}), dst, int(x), y, f[0])
r.DrawStringAt(string([]rune{0xfffd}), dst, int(x), y, f.ResolveFace(0xfffd))
return
}
}
Expand All @@ -118,8 +178,8 @@ func loadMeasureFont(data fyne.Resource) font.Face {

// MeasureString returns how far dot would advance by drawing s with f.
// Tabs are translated into a dot location change.
func MeasureString(f []font.Face, s string, textSize float32, tabWidth int) (size fyne.Size, advance float32) {
return walkString(f, s, float32ToFixed266(textSize), tabWidth, &advance, 1, func(shaping.Output, float32) {})
func MeasureString(f shaping.Fontmap, s string, textSize float32, style fyne.TextStyle) (size fyne.Size, advance float32) {
return walkString(f, s, float32ToFixed266(textSize), style, &advance, 1, func(shaping.Output, float32) {})
}

// RenderedTextSize looks up how big a string would be if drawn on screen.
Expand All @@ -145,7 +205,7 @@ func float32ToFixed266(f float32) fixed.Int26_6 {

func measureText(text string, fontSize float32, style fyne.TextStyle) (fyne.Size, float32) {
face := CachedFontFace(style, fontSize, 1)
return MeasureString(face.Fonts, text, fontSize, style.TabWidth)
return MeasureString(face.Fonts, text, fontSize, style)
}

func tabStop(spacew, x float32, tabWidth int) float32 {
Expand All @@ -158,7 +218,7 @@ func tabStop(spacew, x float32, tabWidth int) float32 {
return tabw * float32(tabs)
}

func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth int, advance *float32, scale float32,
func walkString(faces shaping.Fontmap, s string, textSize fixed.Int26_6, style fyne.TextStyle, advance *float32, scale float32,
cb func(run shaping.Output, x float32)) (size fyne.Size, base float32) {
s = strings.ReplaceAll(s, "\r", "")

Expand All @@ -168,7 +228,7 @@ func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth in
RunStart: 0,
RunEnd: 1,
Direction: di.DirectionLTR,
Face: faces[0],
Face: faces.ResolveFace(' '),
Size: textSize,
}
shaper := &shaping.HarfbuzzShaper{}
Expand All @@ -180,7 +240,10 @@ func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth in

x := float32(0)
spacew := scale * fontTabSpaceSize
ins := shaping.SplitByFontGlyphs(in, faces)
if style.Monospace {
spacew = scale * fixed266ToFloat32(out.Advance)
}
ins := shaping.SplitByFace(in, faces)
for _, in := range ins {
inEnd := in.RunEnd

Expand All @@ -189,10 +252,9 @@ func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth in
if r == '\t' {
if pending {
in.RunEnd = i
out = shaper.Shape(in)
x = shapeCallback(shaper, in, out, x, scale, cb)
x = shapeCallback(shaper, in, x, scale, cb)
}
x = tabStop(spacew, x, tabWidth)
x = tabStop(spacew, x, style.TabWidth)

in.RunStart = i + 1
in.RunEnd = inEnd
Expand All @@ -202,16 +264,16 @@ func walkString(faces []font.Face, s string, textSize fixed.Int26_6, tabWidth in
}
}

x = shapeCallback(shaper, in, out, x, scale, cb)
x = shapeCallback(shaper, in, x, scale, cb)
}

*advance = x
return fyne.NewSize(*advance, fixed266ToFloat32(out.LineBounds.LineThickness())),
fixed266ToFloat32(out.LineBounds.Ascent)
}

func shapeCallback(shaper shaping.Shaper, in shaping.Input, out shaping.Output, x, scale float32, cb func(shaping.Output, float32)) float32 {
out = shaper.Shape(in)
func shapeCallback(shaper shaping.Shaper, in shaping.Input, x, scale float32, cb func(shaping.Output, float32)) float32 {
out := shaper.Shape(in)
glyphs := out.Glyphs
start := 0
pending := false
Expand Down Expand Up @@ -248,7 +310,37 @@ func shapeCallback(shaper shaping.Shaper, in shaping.Input, out shaping.Output,
}

type FontCacheItem struct {
Fonts []font.Face
Fonts shaping.Fontmap
}

var fontCache = &sync.Map{} // map[fyne.TextStyle]*FontCacheItem

type noopLogger struct{}

func (n noopLogger) Printf(string, ...interface{}) {}

type dynamicFontMap struct {
faces []font.Face
family string
}

func (d *dynamicFontMap) ResolveFace(r rune) font.Face {

for _, f := range d.faces {
if _, ok := f.NominalGlyph(r); ok {
return f
}
}

toAdd := lookupRuneFont(r, d.family, metadata.Aspect{})
if toAdd != nil {
d.addFace(toAdd)
return toAdd
}

return d.faces[0]
}

func (d *dynamicFontMap) addFace(f font.Face) {
d.faces = append(d.faces, f)
}
8 changes: 8 additions & 0 deletions internal/painter/font_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
//go:build test

package painter

import "github.com/go-text/typesetting/fontscan"

//
//func Test_compositeFace_Close(t *testing.T) {
// chosenFont := &truetype.Font{}
Expand Down Expand Up @@ -334,3 +338,7 @@ package painter
// f.IndexInvoked = true
// return f.IndexFunc(r)
//}

func loadSystemFonts(fm *fontscan.FontMap) error {
return nil
}
21 changes: 21 additions & 0 deletions internal/painter/font_prod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build !test

package painter

import (
"os"
"path/filepath"
"runtime"

"github.com/go-text/typesetting/fontscan"
)

func loadSystemFonts(fm *fontscan.FontMap) error {
cacheDir := ""
if runtime.GOOS == "android" {
parent := os.Getenv("FILESDIR")
cacheDir = filepath.Join(parent, "fontcache")
}

return fm.UseSystemFonts(cacheDir)
}
14 changes: 9 additions & 5 deletions internal/painter/font_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/internal/painter"
intTest "fyne.io/fyne/v2/internal/test"
"fyne.io/fyne/v2/test"
)

Expand All @@ -28,8 +29,8 @@ func TestCachedFontFace(t *testing.T) {
t.Run(name, func(t *testing.T) {
got := painter.CachedFontFace(tt.style, 14, 1)
for _, r := range tt.runes {
_, ok := got.Fonts[0].NominalGlyph(r)
assert.True(t, ok, "symbol Font should include: %c", r)
f := got.Fonts.ResolveFace(r)
assert.NotNil(t, f, "symbol Font should include: %c", r)
}
})
}
Expand Down Expand Up @@ -77,7 +78,9 @@ func TestDrawString(t *testing.T) {
t.Run(name, func(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 300, 100))
f := painter.CachedFontFace(tt.style, tt.size, 1)
painter.DrawString(img, tt.string, tt.color, f.Fonts, tt.size, 1, tt.tabWidth)

fontMap := &intTest.FontMap{f.Fonts.ResolveFace(' ')} // first (ascii) font
painter.DrawString(img, tt.string, tt.color, fontMap, tt.size, 1, fyne.TextStyle{TabWidth: tt.tabWidth})
test.AssertImageMatches(t, "font/"+tt.want, img)
})
}
Expand Down Expand Up @@ -114,8 +117,9 @@ func TestMeasureString(t *testing.T) {
},
} {
t.Run(name, func(t *testing.T) {
face := painter.CachedFontFace(tt.style, tt.size, 1)
got, _ := painter.MeasureString(face.Fonts, tt.string, tt.size, tt.tabWidth)
faces := painter.CachedFontFace(tt.style, tt.size, 1)
fontMap := &intTest.FontMap{faces.Fonts.ResolveFace(' ')} // first (ascii) font
got, _ := painter.MeasureString(fontMap, tt.string, tt.size, fyne.TextStyle{TabWidth: tt.tabWidth})
assert.Equal(t, tt.want, got.Width)
})
}
Expand Down
2 changes: 1 addition & 1 deletion internal/painter/gl/texture.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (p *painter) newGlTextTexture(obj fyne.CanvasObject) Texture {
img := image.NewNRGBA(image.Rect(0, 0, width, height))

face := paint.CachedFontFace(text.TextStyle, text.TextSize*p.canvas.Scale(), p.texScale)
paint.DrawString(img, text.Text, color, face.Fonts, text.TextSize, p.pixScale, text.TextStyle.TabWidth)
paint.DrawString(img, text.Text, color, face.Fonts, text.TextSize, p.pixScale, text.TextStyle)
return p.imgToTexture(img, canvas.ImageScaleSmooth)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/painter/software/draw.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func drawText(c fyne.Canvas, text *canvas.Text, pos fyne.Position, base *image.N
}

face := painter.CachedFontFace(text.TextStyle, text.TextSize*c.Scale(), 1)
painter.DrawString(txtImg, text.Text, color, face.Fonts, text.TextSize, c.Scale(), text.TextStyle.TabWidth)
painter.DrawString(txtImg, text.Text, color, face.Fonts, text.TextSize, c.Scale(), text.TextStyle)

size := text.Size()
offsetX := float32(0)
Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.