From 5b40d3cd47f6e32fe037ac83b332aa0e16a7eeb8 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Fri, 16 Dec 2022 13:11:41 -0500 Subject: [PATCH] text: provide start of paragraph glyph marker This commit adds a new flag to glyphs indicating that they are the beginning of a new paragraph, as well as adding a guarantee that a glyph with this flag will always follow a glyph with FlagParagraphBreak, even if a paragraph break is the last rune in the text. This helps widgets to find the boundaries and positions of text ending with newlines reliably. Signed-off-by: Chris Waldon --- text/shaper.go | 56 ++++++++++++++++++----- text/shaper_test.go | 108 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 128 insertions(+), 36 deletions(-) diff --git a/text/shaper.go b/text/shaper.go index eb83ab0d..85e82f1c 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -96,19 +96,28 @@ const ( // sequence of glyphs which are logically a single unit, but require multiple // symbols from a font to display. FlagClusterBreak - // FlagSynthetic indicates that the glyph cluster does not represent actual + // FlagParagraphBreak indicates that the glyph cluster does not represent actual // font glyphs, but was inserted by the shaper to represent line-breaking - // whitespace characters. - FlagSynthetic + // whitespace characters. After a glyph with FlagParagraphBreak set, the shaper + // will always return a glyph with FlagParagraphStart providing the X and Y + // coordinates of the start of the next line, even if that line has no contents. + FlagParagraphBreak + // FlagParagraphStart indicates that the glyph starts a new paragraph. + FlagParagraphStart ) func (f Flags) String() string { var b strings.Builder - if f&FlagSynthetic > 0 { + if f&FlagParagraphStart > 0 { b.WriteString("S") } else { b.WriteString("_") } + if f&FlagParagraphBreak > 0 { + b.WriteString("P") + } else { + b.WriteString("_") + } if f&FlagTowardOrigin > 0 { b.WriteString("T") } else { @@ -144,10 +153,12 @@ type Shaper struct { reader strings.Reader // Iterator state. - txt document - line int - run int - glyph int + brokeParagraph bool + pararagraphStart Glyph + txt document + line int + run int + glyph int // advance is the width of glyphs from the current run that have already been displayed. advance fixed.Int26_6 // done tracks whether iteration is over. @@ -223,7 +234,7 @@ func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system } done = endByte == len(str) } - if startByte != endByte || len(l.paragraph) > 0 { + if startByte != endByte || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) { l.txt.append(l.layoutParagraph(params, minWidth, maxWidth, lc, str[startByte:endByte], l.paragraph)) if truncating { params.MaxLines = maxLines - len(l.txt.lines) @@ -275,6 +286,10 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { } for { if l.line == len(l.txt.lines) { + if l.brokeParagraph { + l.brokeParagraph = false + return l.pararagraphStart, true + } if l.err == nil { l.err = io.EOF } @@ -297,7 +312,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { X: align, Y: int32(line.yOffset), Runes: 0, - Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak | FlagSynthetic, + Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak, Ascent: line.ascent, Descent: line.descent, }, true @@ -352,6 +367,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { if endOfLine { glyph.Flags |= FlagLineBreak } + endOfText := endOfLine && l.line == len(l.txt.lines)-1 nextGlyph := l.glyph if rtl { nextGlyph = len(run.Glyphs) - 1 - nextGlyph @@ -365,8 +381,26 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { if run.Direction.Progression() == system.TowardOrigin { glyph.Flags |= FlagTowardOrigin } + if l.brokeParagraph { + glyph.Flags |= FlagParagraphStart + l.brokeParagraph = false + } if g.glyphCount == 0 { - glyph.Flags |= FlagSynthetic + glyph.Flags |= FlagParagraphBreak + l.brokeParagraph = true + if endOfText { + l.pararagraphStart = Glyph{ + Ascent: glyph.Ascent, + Descent: glyph.Descent, + Flags: FlagParagraphStart | FlagLineBreak | FlagRunBreak | FlagClusterBreak, + } + // If a glyph is both a paragraph break and the final glyph, it's a newline + // at the end of the text. We must inform widgets like the text editor + // of a valid cursor position they can use for "after" such a newline, + // taking text alignment into account. + l.pararagraphStart.X = l.txt.alignment.Align(line.direction, 0, l.txt.alignWidth) + l.pararagraphStart.Y = glyph.Y + int32((glyph.Ascent + glyph.Descent).Ceil()) + } } return glyph, true diff --git a/text/shaper_test.go b/text/shaper_test.go index d14e92a4..a150c3dc 100644 --- a/text/shaper_test.go +++ b/text/shaper_test.go @@ -1,12 +1,14 @@ package text import ( + "fmt" "strings" "testing" nsareg "eliasnaur.com/font/noto/sans/arabic/regular" "gioui.org/font/opentype" "gioui.org/io/system" + "golang.org/x/exp/slices" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/math/fixed" ) @@ -16,7 +18,7 @@ import ( func TestWrappingTruncation(t *testing.T) { // Use a test string containing multiple newlines to ensure that they are shaped // as separate paragraphs. - textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua." + textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n" ltrFace, _ := opentype.Parse(goregular.TTF) collection := []FontFace{{Face: ltrFace}} cache := NewShaper(collection) @@ -33,10 +35,18 @@ func TestWrappingTruncation(t *testing.T) { MaxLines: i, }, 200, 200, english, textInput) lineCount := len(cache.txt.lines) - if i <= untruncatedCount && lineCount != i { - t.Errorf("expected %d lines, got %d", i, lineCount) - } else if i > untruncatedCount && lineCount != untruncatedCount { - t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount) + glyphs := []Glyph{} + for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() { + glyphs = append(glyphs, g) + } + if i <= untruncatedCount { + if lineCount != i { + t.Errorf("expected %d lines, got %d", i, lineCount) + } + } else if i > untruncatedCount { + if lineCount != untruncatedCount { + t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount) + } } } } @@ -44,26 +54,74 @@ func TestWrappingTruncation(t *testing.T) { // TestShapingNewlineHandling checks that the shaper's newline splitting behaves // consistently and does not create spurious lines of text. func TestShapingNewlineHandling(t *testing.T) { - // Use a test string containing multiple newlines to ensure that they are shaped - // as separate paragraphs. - textInput := "\n" - ltrFace, _ := opentype.Parse(goregular.TTF) - collection := []FontFace{{Face: ltrFace}} - cache := NewShaper(collection) - cache.LayoutString(Parameters{ - Alignment: Middle, - PxPerEm: fixed.I(10), - }, 200, 200, english, textInput) - if lineCount := len(cache.txt.lines); lineCount > 1 { - t.Errorf("shaping string %q created %d lines", textInput, lineCount) + type testcase struct { + textInput string + expectedLines int + expectedGlyphs int } + for _, tc := range []testcase{ + {textInput: "a\n", expectedLines: 1, expectedGlyphs: 3}, + {textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3}, + {textInput: "", expectedLines: 1, expectedGlyphs: 1}, + } { + t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) { + ltrFace, _ := opentype.Parse(goregular.TTF) + collection := []FontFace{{Face: ltrFace}} + cache := NewShaper(collection) + checkGlyphs := func() { + glyphs := []Glyph{} + for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() { + glyphs = append(glyphs, g) + } + if len(glyphs) != tc.expectedGlyphs { + t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs)) + } + findBreak := func(g Glyph) bool { + return g.Flags&FlagParagraphBreak != 0 + } + found := 0 + for idx := slices.IndexFunc(glyphs, findBreak); idx != -1; idx = slices.IndexFunc(glyphs, findBreak) { + found++ + breakGlyph := glyphs[idx] + startGlyph := glyphs[idx+1] + glyphs = glyphs[idx+1:] + if flags := breakGlyph.Flags; flags&FlagParagraphBreak == 0 { + t.Errorf("expected newline glyph to have P flag, got %s", flags) + } + if flags := startGlyph.Flags; flags&FlagParagraphStart == 0 { + t.Errorf("expected newline glyph to have S flag, got %s", flags) + } + breakX, breakY := breakGlyph.X, breakGlyph.Y + startX, startY := startGlyph.X, startGlyph.Y + if breakX == startX { + t.Errorf("expected paragraph start glyph to have cursor x") + } + if breakY == startY { + t.Errorf("expected paragraph start glyph to have cursor y") + } + } + if count := strings.Count(tc.textInput, "\n"); found != count { + t.Errorf("expected %d paragraph breaks, found %d", count, found) + } + } + cache.LayoutString(Parameters{ + Alignment: Middle, + PxPerEm: fixed.I(10), + }, 200, 200, english, tc.textInput) + if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines { + t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount) + } + checkGlyphs() - cache.Layout(Parameters{ - Alignment: Middle, - PxPerEm: fixed.I(10), - }, 200, 200, english, strings.NewReader(textInput)) - if lineCount := len(cache.txt.lines); lineCount > 1 { - t.Errorf("shaping reader %q created %d lines", textInput, lineCount) + cache.Layout(Parameters{ + Alignment: Middle, + PxPerEm: fixed.I(10), + }, 200, 200, english, strings.NewReader(tc.textInput)) + if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines { + t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount) + } + checkGlyphs() + }) } } @@ -88,7 +146,7 @@ func TestCacheEmptyString(t *testing.T) { checkFlag(t, true, FlagClusterBreak, glyph, 0) checkFlag(t, true, FlagRunBreak, glyph, 0) checkFlag(t, true, FlagLineBreak, glyph, 0) - checkFlag(t, true, FlagSynthetic, glyph, 0) + checkFlag(t, false, FlagParagraphBreak, glyph, 0) if glyph.Ascent == 0 { t.Errorf("expected non-zero ascent") } @@ -205,7 +263,7 @@ func TestCacheGlyphConverstion(t *testing.T) { 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, synthetic, FlagParagraphBreak, actual, glyphCursor) checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor) glyphCursor++ if glyphIdx == end {