package painter import ( "bytes" "image/color" "image/draw" "math" "strings" "sync" "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/shaping" "golang.org/x/image/math/fixed" "fyne.io/fyne/v2" "fyne.io/fyne/v2/internal/async" "fyne.io/fyne/v2/internal/cache" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/theme" ) const ( // DefaultTabWidth is the default width in spaces DefaultTabWidth = 4 fontTabSpaceSize = 10 ) var ( fm *fontscan.FontMap fontScanLock sync.Mutex loaded bool ) func loadMap() { loaded = true fm = fontscan.NewFontMap(noopLogger{}) err := loadSystemFonts(fm) if err != nil { fm = nil // just don't fallback } } func lookupLangFont(family string, aspect font.Aspect) *font.Face { fontScanLock.Lock() defer fontScanLock.Unlock() if !loaded { 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 font.Aspect) *font.Face { fontScanLock.Lock() defer fontScanLock.Unlock() if !loaded { loadMap() } if fm == nil { return nil } fm.SetQuery(fontscan.Query{Families: []string{family}, Aspect: aspect}) return fm.ResolveFace(r) } func lookupFaces(theme, fallback, emoji 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 := font.Aspect{Style: font.StyleNormal} if style.Italic { aspect.Style = font.StyleItalic } if style.Bold { aspect.Weight = font.WeightBold } if emoji != nil { faces.addFace(loadMeasureFont(emoji)) } 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, source fyne.Resource, o fyne.CanvasObject) *FontCacheItem { if source != nil { val, ok := fontCustomCache.Load(source) if !ok { face := loadMeasureFont(source) if face == nil { face = loadMeasureFont(theme.TextFont()) } faces := &dynamicFontMap{family: source.Name(), faces: []*font.Face{face}} val = &FontCacheItem{Fonts: faces} fontCustomCache.Store(source, val) } return val } scope := "" if o != nil { // for overridden themes get the cache key right scope = cache.WidgetScopeID(o) } val, ok := fontCache.Load(cacheID{style: style, scope: scope}) if !ok { var faces *dynamicFontMap th := theme.CurrentForWidget(o) font1 := th.Font(style) emoji := theme.DefaultEmojiFont() // TODO only one emoji - maybe others too switch { case style.Monospace: faces = lookupFaces(font1, theme.DefaultTextMonospaceFont(), emoji, fontscan.Monospace, style) case style.Bold: if style.Italic { faces = lookupFaces(font1, theme.DefaultTextBoldItalicFont(), emoji, fontscan.SansSerif, style) } else { faces = lookupFaces(font1, theme.DefaultTextBoldFont(), emoji, fontscan.SansSerif, style) } case style.Italic: faces = lookupFaces(font1, theme.DefaultTextItalicFont(), emoji, fontscan.SansSerif, style) case style.Symbol: 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: faces = lookupFaces(font1, theme.DefaultTextFont(), emoji, fontscan.SansSerif, style) } val = &FontCacheItem{Fonts: faces} fontCache.Store(cacheID{style: style, scope: scope}, val) } return val } // ClearFontCache is used to remove cached fonts in the case that we wish to re-load Font faces func ClearFontCache() { fontCache.Clear() fontCustomCache.Clear() } // DrawString draws a string into an image. 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, } advance := float32(0) 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.ResolveFace(0xfffd)) return } } r.DrawShapedRunAt(run, dst, int(x), y) }) } func loadMeasureFont(data fyne.Resource) *font.Face { loaded, err := font.ParseTTF(bytes.NewReader(data.Content())) if err != nil { fyne.LogError("font load error", err) return nil } return loaded } // MeasureString returns how far dot would advance by drawing s with f. // Tabs are translated into a dot location change. 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. // It also returns the distance from top to the text baseline. func RenderedTextSize(text string, fontSize float32, style fyne.TextStyle, source fyne.Resource) (size fyne.Size, baseline float32) { size, base := cache.GetFontMetrics(text, fontSize, style, source) if base != 0 { return size, base } size, base = measureText(text, fontSize, style, source) cache.SetFontMetrics(text, fontSize, style, source, size, base) return size, base } func fixed266ToFloat32(i fixed.Int26_6) float32 { return float32(float64(i) / (1 << 6)) } func float32ToFixed266(f float32) fixed.Int26_6 { return fixed.Int26_6(float64(f) * (1 << 6)) } func measureText(text string, fontSize float32, style fyne.TextStyle, source fyne.Resource) (fyne.Size, float32) { face := CachedFontFace(style, source, nil) return MeasureString(face.Fonts, text, fontSize, style) } func tabStop(spacew, x float32, tabWidth int) float32 { if tabWidth <= 0 { tabWidth = DefaultTabWidth } tabw := spacew * float32(tabWidth) tabs, _ := math.Modf(float64((x + tabw) / tabw)) return tabw * float32(tabs) } 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", "") runes := []rune(s) in := shaping.Input{ Text: []rune{' '}, RunStart: 0, RunEnd: 1, Direction: di.DirectionLTR, Face: faces.ResolveFace(' '), Size: textSize, } shaper := &shaping.HarfbuzzShaper{} segmenter := &shaping.Segmenter{} out := shaper.Shape(in) in.Text = runes in.RunStart = 0 in.RunEnd = len(runes) x := float32(0) spacew := scale * fontTabSpaceSize if style.Monospace { spacew = scale * fixed266ToFloat32(out.Advance) } ins := segmenter.Split(in, faces) for _, in := range ins { inEnd := in.RunEnd pending := false for i, r := range in.Text[in.RunStart:in.RunEnd] { if r == '\t' { if pending { in.RunEnd = i x = shapeCallback(shaper, in, x, scale, cb) } x = tabStop(spacew, x, style.TabWidth) in.RunStart = i + 1 in.RunEnd = inEnd pending = false } else { pending = true } } 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, x, scale float32, cb func(shaping.Output, float32)) float32 { out := shaper.Shape(in) glyphs := out.Glyphs start := 0 pending := false adv := fixed.I(0) for i, g := range out.Glyphs { if g.GlyphID == 0 { if pending { out.Glyphs = glyphs[start:i] cb(out, x) x += fixed266ToFloat32(adv) * scale adv = 0 } out.Glyphs = glyphs[i : i+1] cb(out, x) x += fixed266ToFloat32(glyphs[i].XAdvance) * scale adv = 0 start = i + 1 pending = false } else { pending = true } adv += g.XAdvance } if pending { out.Glyphs = glyphs[start:] cb(out, x) x += fixed266ToFloat32(adv) * scale adv = 0 } return x + fixed266ToFloat32(adv)*scale } type FontCacheItem struct { Fonts shaping.Fontmap } type cacheID struct { style fyne.TextStyle scope string } var fontCache async.Map[cacheID, *FontCacheItem] var fontCustomCache async.Map[fyne.Resource, *FontCacheItem] // for custom resources type noopLogger struct{} func (n noopLogger) Printf(string, ...any) {} 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, font.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) }