mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
font/{gofont,opentype},text,widget{,/material}: [API] add font fallback and bidi support
This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.
The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:
// Prepare some fonts.
var collection []text.FontFace
// Make a shaper with those fonts loaded.
shaper := text.NewShaper(collection)
// Shape a string.
shaper.LayoutString(text.Parameters{
PxPerEm: fixed.I(12),
}, 0, 100, system.Locale{}, "Hello")
// Iterate the glyphs from that string.
for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
// Convert the glyph data into a path. In real uses, convert batches of glyphs
// rather than single glyphs to reduce the number of individual paths and offsets
// required to display your text.
shape := shaper.Shape([]text.Glyph{glyph})
// Offset the glyph to the position it declares within its fields. This will
// automatically handle correct bidirectional text glyph positioning.
offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
// Create a clip area from the shape of the glyph.
area := clip.Outline{Path: shape}.Push(gtx.Ops)
// Paint whatever the current color is within the glyph's shape.
paint.PaintOp{}.Add(gtx.Ops)
area.Pop()
offset.Pop()
}
This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.
Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.
This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.
As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.
Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
+189
-104
@@ -2,117 +2,202 @@ package text
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
|
||||
"gioui.org/font/opentype"
|
||||
"gioui.org/io/system"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
var (
|
||||
testTF1 Typeface = "MockFace"
|
||||
testTF2 Typeface = "TestFace"
|
||||
testTF3 Typeface = "AnotherFace"
|
||||
)
|
||||
// TestCacheEmptyString ensures that shaping the empty string returns a
|
||||
// single synthetic glyph with ascent/descent info.
|
||||
func TestCacheEmptyString(t *testing.T) {
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}}
|
||||
cache := NewShaper(collection)
|
||||
cache.LayoutString(Parameters{
|
||||
Alignment: Middle,
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 200, 200, english, "")
|
||||
glyphs := make([]Glyph, 0, 1)
|
||||
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
|
||||
glyphs = append(glyphs, g)
|
||||
}
|
||||
if len(glyphs) != 1 {
|
||||
t.Errorf("expected %d glyphs, got %d", 1, len(glyphs))
|
||||
}
|
||||
glyph := glyphs[0]
|
||||
checkFlag(t, true, FlagClusterBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagRunBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagLineBreak, glyph, 0)
|
||||
checkFlag(t, true, FlagSynthetic, glyph, 0)
|
||||
if glyph.Ascent == 0 {
|
||||
t.Errorf("expected non-zero ascent")
|
||||
}
|
||||
if glyph.Descent == 0 {
|
||||
t.Errorf("expected non-zero descent")
|
||||
}
|
||||
if glyph.Y == 0 {
|
||||
t.Errorf("expected non-zero y offset")
|
||||
}
|
||||
if glyph.X == 0 {
|
||||
t.Errorf("expected non-zero x offset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosestFontByWeight(t *testing.T) {
|
||||
c := newTestCache(
|
||||
Font{Style: Regular, Weight: Normal},
|
||||
Font{Style: Regular, Weight: Light},
|
||||
Font{Style: Regular, Weight: Bold},
|
||||
Font{Style: Italic, Weight: Thin},
|
||||
)
|
||||
weightOnlyTests := []struct {
|
||||
Lookup Weight
|
||||
Expected Weight
|
||||
}{
|
||||
// Test for existing weights.
|
||||
{Lookup: Normal, Expected: Normal},
|
||||
{Lookup: Light, Expected: Light},
|
||||
{Lookup: Bold, Expected: Bold},
|
||||
// Test for missing weights.
|
||||
{Lookup: Thin, Expected: Light},
|
||||
{Lookup: ExtraLight, Expected: Light},
|
||||
{Lookup: Medium, Expected: Normal},
|
||||
{Lookup: SemiBold, Expected: Bold},
|
||||
{Lookup: ExtraBlack, Expected: Bold},
|
||||
// TestCacheAlignment ensures that shaping with different alignments or dominant
|
||||
// text directions results in different X offsets.
|
||||
func TestCacheAlignment(t *testing.T) {
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}}
|
||||
cache := NewShaper(collection)
|
||||
params := Parameters{Alignment: Start, PxPerEm: fixed.I(10)}
|
||||
cache.LayoutString(params, 200, 200, english, "A")
|
||||
glyph, _ := cache.NextGlyph()
|
||||
startX := glyph.X
|
||||
params.Alignment = Middle
|
||||
cache.LayoutString(params, 200, 200, english, "A")
|
||||
glyph, _ = cache.NextGlyph()
|
||||
middleX := glyph.X
|
||||
params.Alignment = End
|
||||
cache.LayoutString(params, 200, 200, english, "A")
|
||||
glyph, _ = cache.NextGlyph()
|
||||
endX := glyph.X
|
||||
if startX == middleX || startX == endX || endX == middleX {
|
||||
t.Errorf("[LTR] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", startX, middleX, endX)
|
||||
}
|
||||
for _, test := range weightOnlyTests {
|
||||
got, ok := c.closestFont(Font{Typeface: testTF1, Weight: test.Lookup})
|
||||
if !ok {
|
||||
t.Fatalf("expected closest font for %v to exist", test.Lookup)
|
||||
}
|
||||
if got.Weight != test.Expected {
|
||||
t.Fatalf("got weight %v, expected %v", got.Weight, test.Expected)
|
||||
}
|
||||
params.Alignment = Start
|
||||
cache.LayoutString(params, 200, 200, arabic, "A")
|
||||
glyph, _ = cache.NextGlyph()
|
||||
rtlStartX := glyph.X
|
||||
params.Alignment = Middle
|
||||
cache.LayoutString(params, 200, 200, arabic, "A")
|
||||
glyph, _ = cache.NextGlyph()
|
||||
rtlMiddleX := glyph.X
|
||||
params.Alignment = End
|
||||
cache.LayoutString(params, 200, 200, arabic, "A")
|
||||
glyph, _ = cache.NextGlyph()
|
||||
rtlEndX := glyph.X
|
||||
if rtlStartX == rtlMiddleX || rtlStartX == rtlEndX || rtlEndX == rtlMiddleX {
|
||||
t.Errorf("[RTL] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", rtlStartX, rtlMiddleX, rtlEndX)
|
||||
}
|
||||
c = newTestCache(
|
||||
Font{Style: Regular, Weight: Light},
|
||||
Font{Style: Regular, Weight: Bold},
|
||||
Font{Style: Italic, Weight: Normal},
|
||||
Font{Typeface: testTF3, Style: Italic, Weight: Bold},
|
||||
)
|
||||
otherTests := []struct {
|
||||
Lookup Font
|
||||
Expected Font
|
||||
ExpectedToFail bool
|
||||
}{
|
||||
// Test for existing fonts.
|
||||
{
|
||||
Lookup: Font{Typeface: testTF1, Weight: Light},
|
||||
Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
|
||||
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
|
||||
},
|
||||
// Test for missing fonts.
|
||||
{
|
||||
Lookup: Font{Typeface: testTF1, Weight: Normal},
|
||||
Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF3, Style: Italic, Weight: Normal},
|
||||
Expected: Font{Typeface: testTF3, Style: Italic, Weight: Bold},
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Thin},
|
||||
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Bold},
|
||||
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF2, Weight: Normal},
|
||||
ExpectedToFail: true,
|
||||
},
|
||||
{
|
||||
Lookup: Font{Typeface: testTF2, Style: Italic, Weight: Normal},
|
||||
ExpectedToFail: true,
|
||||
},
|
||||
if startX == rtlStartX || endX == rtlEndX {
|
||||
t.Errorf("shaping with with different dominant text directions and the same alignment should not produce the same X unless it's middle-aligned")
|
||||
}
|
||||
for _, test := range otherTests {
|
||||
got, ok := c.closestFont(test.Lookup)
|
||||
if test.ExpectedToFail {
|
||||
if ok {
|
||||
t.Fatalf("expected closest font for %v to not exist", test.Lookup)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
func TestCacheGlyphConverstion(t *testing.T) {
|
||||
ltrFace, _ := opentype.Parse(goregular.TTF)
|
||||
rtlFace, _ := opentype.Parse(nsareg.TTF)
|
||||
collection := []FontFace{{Face: ltrFace}, {Face: rtlFace}}
|
||||
type testcase struct {
|
||||
name string
|
||||
text string
|
||||
locale system.Locale
|
||||
expected []Glyph
|
||||
}
|
||||
for _, tc := range []testcase{
|
||||
{
|
||||
name: "bidi ltr",
|
||||
text: "The quick سماء שלום لا fox تمط שלום\nغير the\nlazy dog.",
|
||||
locale: english,
|
||||
},
|
||||
{
|
||||
name: "bidi rtl",
|
||||
text: "الحب سماء brown привет fox تمط jumps\nпривет over\nغير الأحلام.",
|
||||
locale: arabic,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cache := NewShaper(collection)
|
||||
cache.LayoutString(Parameters{
|
||||
PxPerEm: fixed.I(10),
|
||||
}, 0, 200, tc.locale, tc.text)
|
||||
doc := cache.txt
|
||||
glyphs := make([]Glyph, 0, len(tc.expected))
|
||||
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
|
||||
glyphs = append(glyphs, g)
|
||||
}
|
||||
glyphCursor := 0
|
||||
for _, line := range doc.lines {
|
||||
for runIdx, run := range line.runs {
|
||||
lastRun := runIdx == len(line.runs)-1
|
||||
start := 0
|
||||
end := len(run.Glyphs) - 1
|
||||
inc := 1
|
||||
towardOrigin := false
|
||||
if run.Direction.Progression() == system.TowardOrigin {
|
||||
start = len(run.Glyphs) - 1
|
||||
end = 0
|
||||
inc = -1
|
||||
towardOrigin = true
|
||||
}
|
||||
for glyphIdx := start; ; glyphIdx += inc {
|
||||
endOfRun := glyphIdx == end
|
||||
glyph := run.Glyphs[glyphIdx]
|
||||
endOfCluster := glyphIdx == end || run.Glyphs[glyphIdx+inc].clusterIndex != glyph.clusterIndex
|
||||
|
||||
actual := glyphs[glyphCursor]
|
||||
if actual.ID != glyph.id {
|
||||
t.Errorf("glyphs[%d] expected id %d, got id %d", glyphCursor, glyph.id, actual.ID)
|
||||
}
|
||||
// Synthetic glyphs should only ever show up at the end of lines.
|
||||
endOfLine := lastRun && endOfRun
|
||||
synthetic := glyph.glyphCount == 0 && endOfLine
|
||||
checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
|
||||
checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
|
||||
checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
|
||||
checkFlag(t, synthetic, FlagSynthetic, actual, glyphCursor)
|
||||
checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
|
||||
glyphCursor++
|
||||
if glyphIdx == end {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printLinePositioning(t, doc.lines, glyphs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkFlag(t *testing.T, shouldHave bool, flag Flags, actual Glyph, glyphCursor int) {
|
||||
t.Helper()
|
||||
if shouldHave && actual.Flags&flag == 0 {
|
||||
t.Errorf("glyphs[%d] should have %s set", glyphCursor, flag)
|
||||
} else if !shouldHave && actual.Flags&flag != 0 {
|
||||
t.Errorf("glyphs[%d] should not have %s set", glyphCursor, flag)
|
||||
}
|
||||
}
|
||||
|
||||
func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
|
||||
t.Helper()
|
||||
glyphCursor := 0
|
||||
for i, line := range lines {
|
||||
t.Logf("line %d, dir %s, width %d, visual %v, runeCount: %d", i, line.direction, line.width, line.visualOrder, line.runeCount)
|
||||
for k, run := range line.runs {
|
||||
t.Logf("run: %d, dir %s, width %d, runes {count: %d, offset: %d}", k, run.Direction, run.Advance, run.Runes.Count, run.Runes.Offset)
|
||||
start := 0
|
||||
end := len(run.Glyphs) - 1
|
||||
inc := 1
|
||||
if run.Direction.Progression() == system.TowardOrigin {
|
||||
start = len(run.Glyphs) - 1
|
||||
end = 0
|
||||
inc = -1
|
||||
}
|
||||
for g := start; ; g += inc {
|
||||
glyph := run.Glyphs[g]
|
||||
if glyphCursor < len(glyphs) {
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
|
||||
}
|
||||
glyphCursor++
|
||||
if g == end {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected closest font for %v to exist", test.Lookup)
|
||||
}
|
||||
if got != test.Expected {
|
||||
t.Fatalf("got %v, expected %v", got, test.Expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCache(fonts ...Font) *Cache {
|
||||
c := &Cache{faces: make(map[Font]*faceCache)}
|
||||
c.def = testTF1
|
||||
for _, font := range fonts {
|
||||
if font.Typeface == "" {
|
||||
font.Typeface = testTF1
|
||||
}
|
||||
c.faces[font] = &faceCache{face: nil}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user