From b7d126e24cd727a7ca42abf14dbd45188880ecc8 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Mon, 10 Oct 2022 16:44:22 -0400 Subject: [PATCH] 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 --- app/ime_test.go | 2 +- font/opentype/internal/shaping.go | 521 ------ font/opentype/internal/shaping_test.go | 1522 ----------------- font/opentype/opentype.go | 127 +- font/opentype/opentype_test.go | 45 - font/opentype/testdata/only1.ttf.gz | Bin 442 -> 0 bytes font/opentype/testdata/only2.ttf.gz | Bin 629 -> 0 bytes go.mod | 8 +- go.sum | 16 +- text/gotext.go | 782 +++++++++ text/gotext_test.go | 650 +++++++ text/lru.go | 95 +- text/lru_test.go | 7 +- text/shaper.go | 524 ++++-- text/shaper_test.go | 293 ++-- ...15eb6104cf09159c7d756cc3530ccaa9007c0a2c06 | 5 + ...6a67df3aa3a562e2f2f1cfb665a650f60e7e0ab4e6 | 5 + ...a74279d7e3e486d8ac211e45049cfa4833257ef236 | 5 + ...077b6f4ba91b040119d82d55991d568014f4ba671e | 5 + ...1bc74e2377c0664f64bbf2ac8b1cdd0ce9f55461b3 | 5 + ...857d5487d1a89309f7312ccde5ff8b94fcd29521eb | 5 + ...75cb8900f97fc1c20e19de09fc0ffca8ff0c8d9e5a | 5 + ...39baaace51fa4348bed0e3b662dd50aa341f67d10c | 5 + ...f4ea99e50a6ecd0d4134e3a77234172d13787ac4ea | 5 + ...83f58a3ca4eba895087316b31e598f0b05c978d87c | 5 + ...60599b281593098b0aad864231d756c3e9699029c1 | 5 + text/text.go | 164 +- widget/editor.go | 579 ++----- widget/editor_test.go | 173 +- widget/index.go | 392 +++++ widget/index_test.go | 424 +++++ widget/label.go | 256 +-- widget/material/button.go | 2 +- widget/material/checkable.go | 2 +- widget/material/editor.go | 2 +- widget/material/label.go | 2 +- widget/material/theme.go | 4 +- ...120fe7b82978ac0e5c16afb519beb080f73c470d3f | 4 + ...645d025ac15316f1210d4aa307cde333cc87fdad55 | 4 + .../FuzzEditorEditing/clusterIndexForCrash1 | 4 + .../FuzzEditorEditing/clusterIndexForCrash2 | 4 + widget/text_bench_test.go | 312 ++-- widget/text_test.go | 528 ------ 43 files changed, 3523 insertions(+), 3980 deletions(-) delete mode 100644 font/opentype/internal/shaping.go delete mode 100644 font/opentype/internal/shaping_test.go delete mode 100644 font/opentype/opentype_test.go delete mode 100644 font/opentype/testdata/only1.ttf.gz delete mode 100644 font/opentype/testdata/only2.ttf.gz create mode 100644 text/gotext.go create mode 100644 text/gotext_test.go create mode 100644 text/testdata/fuzz/FuzzLayout/2a7730fcbcc3550718b37415eb6104cf09159c7d756cc3530ccaa9007c0a2c06 create mode 100644 text/testdata/fuzz/FuzzLayout/3fc3ee939f0df44719bee36a67df3aa3a562e2f2f1cfb665a650f60e7e0ab4e6 create mode 100644 text/testdata/fuzz/FuzzLayout/594e4fda2e3462061d50b6a74279d7e3e486d8ac211e45049cfa4833257ef236 create mode 100644 text/testdata/fuzz/FuzzLayout/6b452fd81f16c000dbe525077b6f4ba91b040119d82d55991d568014f4ba671e create mode 100644 text/testdata/fuzz/FuzzLayout/6b5a1e9cd750a9aa8dafe11bc74e2377c0664f64bbf2ac8b1cdd0ce9f55461b3 create mode 100644 text/testdata/fuzz/FuzzLayout/be56090b98f3c84da6ff9e857d5487d1a89309f7312ccde5ff8b94fcd29521eb create mode 100644 text/testdata/fuzz/FuzzLayout/dda958d1b1bb9df71f81c575cb8900f97fc1c20e19de09fc0ffca8ff0c8d9e5a create mode 100644 text/testdata/fuzz/FuzzLayout/de31ec6b1ac7797daefecf39baaace51fa4348bed0e3b662dd50aa341f67d10c create mode 100644 text/testdata/fuzz/FuzzLayout/e79034d6c7a6ce74d7a689f4ea99e50a6ecd0d4134e3a77234172d13787ac4ea create mode 100644 text/testdata/fuzz/FuzzLayout/f1f0611baadc20469863e483f58a3ca4eba895087316b31e598f0b05c978d87c create mode 100644 text/testdata/fuzz/FuzzLayout/f2ebea678c72f6c394d7f860599b281593098b0aad864231d756c3e9699029c1 create mode 100644 widget/index.go create mode 100644 widget/index_test.go create mode 100644 widget/testdata/fuzz/FuzzEditorEditing/18c534da60e6b61361786a120fe7b82978ac0e5c16afb519beb080f73c470d3f create mode 100644 widget/testdata/fuzz/FuzzEditorEditing/a489f2d9f9226d13b55846645d025ac15316f1210d4aa307cde333cc87fdad55 create mode 100644 widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash1 create mode 100644 widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash2 delete mode 100644 widget/text_test.go diff --git a/app/ime_test.go b/app/ime_test.go index 814e7b2a..c510c353 100644 --- a/app/ime_test.go +++ b/app/ime_test.go @@ -28,7 +28,7 @@ func FuzzIME(f *testing.F) { f.Add([]byte("20007800002\x02000")) f.Add([]byte("200A02000990\x19002\x17\x0200")) f.Fuzz(func(t *testing.T, cmds []byte) { - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) e := new(widget.Editor) e.Focus() diff --git a/font/opentype/internal/shaping.go b/font/opentype/internal/shaping.go deleted file mode 100644 index 66571d3a..00000000 --- a/font/opentype/internal/shaping.go +++ /dev/null @@ -1,521 +0,0 @@ -package internal - -import ( - "io" - - "gioui.org/io/system" - "gioui.org/text" - "github.com/benoitkugler/textlayout/language" - "github.com/gioui/uax/segment" - "github.com/gioui/uax/uax14" - "github.com/go-text/typesetting/di" - "github.com/go-text/typesetting/font" - "github.com/go-text/typesetting/shaping" - "golang.org/x/image/math/fixed" -) - -// computeGlyphClusters populates the Clusters field of a Layout. -// The order of the clusters is visual, meaning -// that the first cluster is the leftmost cluster displayed even when -// the cluster is part of RTL text. -func computeGlyphClusters(l *text.Layout) { - clusters := make([]text.GlyphCluster, 0, len(l.Glyphs)+1) - if len(l.Glyphs) < 1 { - if l.Runes.Count > 0 { - // Empty line corresponding to a newline character. - clusters = append(clusters, text.GlyphCluster{ - Runes: text.Range{ - Count: 1, - Offset: l.Runes.Offset, - }, - }) - } - l.Clusters = clusters - return - } - rtl := l.Direction == system.RTL - - // Check for trailing whitespace characters and synthesize - // GlyphClusters to represent them. - lastGlyph := l.Glyphs[len(l.Glyphs)-1] - if rtl { - lastGlyph = l.Glyphs[0] - } - trailingNewline := lastGlyph.ClusterIndex+lastGlyph.RuneCount < l.Runes.Count+l.Runes.Offset - newlineCluster := text.GlyphCluster{ - Runes: text.Range{ - Count: 1, - Offset: l.Runes.Count + l.Runes.Offset - 1, - }, - Glyphs: text.Range{ - Offset: len(l.Glyphs), - }, - } - - var ( - i int = 0 - inc int = 1 - runesProcessed int = 0 - glyphsProcessed int = 0 - ) - - if rtl { - i = len(l.Glyphs) - 1 - inc = -inc - glyphsProcessed = len(l.Glyphs) - 1 - newlineCluster.Glyphs.Offset = 0 - } - // Construct clusters from the line's glyphs. - for ; i < len(l.Glyphs) && i >= 0; i += inc { - g := l.Glyphs[i] - xAdv := g.XAdvance * fixed.Int26_6(inc) - for k := 0; k < g.GlyphCount-1 && k < len(l.Glyphs); k++ { - i += inc - xAdv += l.Glyphs[i].XAdvance * fixed.Int26_6(inc) - } - - startRune := runesProcessed - runeIncrement := g.RuneCount - startGlyph := glyphsProcessed - glyphIncrement := g.GlyphCount * inc - if rtl { - startGlyph = glyphsProcessed + glyphIncrement + 1 - } - clusters = append(clusters, text.GlyphCluster{ - Advance: xAdv, - Runes: text.Range{ - Count: g.RuneCount, - Offset: startRune + l.Runes.Offset, - }, - Glyphs: text.Range{ - Count: g.GlyphCount, - Offset: startGlyph, - }, - }) - runesProcessed += runeIncrement - glyphsProcessed += glyphIncrement - } - // Insert synthetic clusters at the right edge of the line. - if trailingNewline { - clusters = append(clusters, newlineCluster) - } - l.Clusters = clusters -} - -// langConfig describes the language and writing system of a body of text. -type langConfig struct { - // Language the text is written in. - language.Language - // Writing system used to represent the text. - language.Script - // Direction of the text, usually driven by the writing system. - di.Direction -} - -// mapRunesToClusterIndices returns a slice. Each index within that slice corresponds -// to an index within the runes input slice. The value stored at that index is the -// index of the glyph at the start of the corresponding glyph cluster shaped by -// harfbuzz. -func mapRunesToClusterIndices(runes []rune, glyphs []shaping.Glyph) []int { - mapping := make([]int, len(runes)) - glyphCursor := 0 - if len(runes) == 0 { - return nil - } - // If the final cluster values are lower than the starting ones, - // the text is RTL. - rtl := len(glyphs) > 0 && glyphs[len(glyphs)-1].ClusterIndex < glyphs[0].ClusterIndex - if rtl { - glyphCursor = len(glyphs) - 1 - } - for i := range runes { - for glyphCursor >= 0 && glyphCursor < len(glyphs) && - ((rtl && glyphs[glyphCursor].ClusterIndex <= i) || - (!rtl && glyphs[glyphCursor].ClusterIndex < i)) { - if rtl { - glyphCursor-- - } else { - glyphCursor++ - } - } - if rtl { - glyphCursor++ - } else if (glyphCursor >= 0 && glyphCursor < len(glyphs) && - glyphs[glyphCursor].ClusterIndex > i) || - (glyphCursor == len(glyphs) && len(glyphs) > 1) { - glyphCursor-- - targetClusterIndex := glyphs[glyphCursor].ClusterIndex - for glyphCursor-1 >= 0 && glyphs[glyphCursor-1].ClusterIndex == targetClusterIndex { - glyphCursor-- - } - } - if glyphCursor < 0 { - glyphCursor = 0 - } else if glyphCursor >= len(glyphs) { - glyphCursor = len(glyphs) - 1 - } - mapping[i] = glyphCursor - } - return mapping -} - -// inclusiveGlyphRange returns the inclusive range of runes and glyphs matching -// the provided start and breakAfter rune positions. -// runeToGlyph must be a valid mapping from the rune representation to the -// glyph reprsentation produced by mapRunesToClusterIndices. -// numGlyphs is the number of glyphs in the output representing the runes -// under consideration. -func inclusiveGlyphRange(start, breakAfter int, runeToGlyph []int, numGlyphs int) (glyphStart, glyphEnd int) { - rtl := runeToGlyph[len(runeToGlyph)-1] < runeToGlyph[0] - runeStart := start - runeEnd := breakAfter - if rtl { - glyphStart = runeToGlyph[runeEnd] - if runeStart-1 >= 0 { - glyphEnd = runeToGlyph[runeStart-1] - 1 - } else { - glyphEnd = numGlyphs - 1 - } - } else { - glyphStart = runeToGlyph[runeStart] - if runeEnd+1 < len(runeToGlyph) { - glyphEnd = runeToGlyph[runeEnd+1] - 1 - } else { - glyphEnd = numGlyphs - 1 - } - } - return -} - -// breakOption represets a location within the rune slice at which -// it may be safe to break a line of text. -type breakOption struct { - // breakAtRune is the index at which it is safe to break. - breakAtRune int - // penalty is the cost of breaking at this index. Negative - // penalties mean that the break is beneficial, and a penalty - // of uax14.PenaltyForMustBreak means a required break. - penalty int -} - -// getBreakOptions returns a slice of line break candidates for the -// text in the provided slice. -func getBreakOptions(text []rune) []breakOption { - // Collect options for breaking the lines in a slice. - var options []breakOption - const adjust = -1 - breaker := uax14.NewLineWrap() - segmenter := segment.NewSegmenter(breaker) - segmenter.InitFromSlice(text) - runeOffset := 0 - brokeAtEnd := false - for segmenter.Next() { - penalty, _ := segmenter.Penalties() - // Determine the indices of the breaking runes in the runes - // slice. Would be nice if the API provided this. - currentSegment := segmenter.Runes() - runeOffset += len(currentSegment) - - // Collect all break options. - options = append(options, breakOption{ - penalty: penalty, - breakAtRune: runeOffset + adjust, - }) - if options[len(options)-1].breakAtRune == len(text)-1 { - brokeAtEnd = true - } - } - if len(text) > 0 && !brokeAtEnd { - options = append(options, breakOption{ - penalty: uax14.PenaltyForMustBreak, - breakAtRune: len(text) - 1, - }) - } - return options -} - -type Shaper func(shaping.Input) (shaping.Output, error) - -// paragraph shapes a single paragraph of text, breaking it into multiple lines -// to fit within the provided maxWidth. -func paragraph(shaper Shaper, face font.Face, ppem fixed.Int26_6, maxWidth int, lc langConfig, paragraph []rune) ([]output, error) { - // TODO: handle splitting bidi text here - - // Shape the text. - input := toInput(face, ppem, lc, paragraph) - out, err := shaper(input) - if err != nil { - return nil, err - } - // Get a mapping from input runes to output glyphs. - runeToGlyph := mapRunesToClusterIndices(paragraph, out.Glyphs) - - // Fetch line break candidates. - breaks := getBreakOptions(paragraph) - - return lineWrap(out, input.Direction, paragraph, runeToGlyph, breaks, maxWidth), nil -} - -// shouldKeepSegmentOnLine decides whether the segment of text from the current -// end of the line to the provided breakOption should be kept on the current -// line. It should be called successively with each available breakOption, -// and the line should be broken (without keeping the current segment) -// whenever it returns false. -// -// The parameters require some explanation: -// - out - the shaping.Output that is being line-broken. -// - runeToGlyph - a mapping where accessing the slice at the index of a rune -// into out will yield the index of the first glyph corresponding to that rune. -// - lineStartRune - the index of the first rune in the line. -// - b - the line break candidate under consideration. -// - curLineWidth - the amount of space total in the current line. -// - curLineUsed - the amount of space in the current line that is already used. -// - nextLineWidth - the amount of space available on the next line. -// -// This function returns both a valid shaping.Output broken at b and a boolean -// indicating whether the returned output should be used. -func shouldKeepSegmentOnLine(out shaping.Output, runeToGlyph []int, lineStartRune int, b breakOption, curLineWidth, curLineUsed, nextLineWidth int) (candidateLine shaping.Output, keep bool) { - // Convert the break target to an inclusive index. - glyphStart, glyphEnd := inclusiveGlyphRange(lineStartRune, b.breakAtRune, runeToGlyph, len(out.Glyphs)) - - // Construct a line out of the inclusive glyph range. - candidateLine = out - candidateLine.Glyphs = candidateLine.Glyphs[glyphStart : glyphEnd+1] - candidateLine.RecomputeAdvance() - candidateAdvance := candidateLine.Advance.Ceil() - if candidateAdvance > curLineWidth && candidateAdvance-curLineUsed <= nextLineWidth { - // If it fits on the next line, put it there. - return candidateLine, false - } - - return candidateLine, true -} - -// lineWrap wraps the shaped glyphs of a paragraph to a particular max width. -func lineWrap(out shaping.Output, dir di.Direction, paragraph []rune, runeToGlyph []int, breaks []breakOption, maxWidth int) []output { - var outputs []output - if len(breaks) == 0 { - // Pass empty lines through as empty. - outputs = append(outputs, output{ - Shaped: out, - RuneRange: text.Range{ - Count: len(paragraph), - }, - }) - return outputs - } - - for i := 0; i < len(breaks); i++ { - b := breaks[i] - if b.breakAtRune+1 < len(runeToGlyph) { - // Check if this break is valid. - gIdx := runeToGlyph[b.breakAtRune] - g2Idx := runeToGlyph[b.breakAtRune+1] - cIdx := out.Glyphs[gIdx].ClusterIndex - c2Idx := out.Glyphs[g2Idx].ClusterIndex - if cIdx == c2Idx { - // This break is within a harfbuzz cluster, and is - // therefore invalid. - copy(breaks[i:], breaks[i+1:]) - breaks = breaks[:len(breaks)-1] - i-- - } - } - } - - start := 0 - runesProcessed := 0 - for i := 0; i < len(breaks); i++ { - b := breaks[i] - // Always keep the first segment on a line. - good, _ := shouldKeepSegmentOnLine(out, runeToGlyph, start, b, maxWidth, 0, maxWidth) - end := b.breakAtRune - innerLoop: - for k := i + 1; k < len(breaks); k++ { - bb := breaks[k] - candidate, ok := shouldKeepSegmentOnLine(out, runeToGlyph, start, bb, maxWidth, good.Advance.Ceil(), maxWidth) - if ok { - // Use this new, longer segment. - good = candidate - end = bb.breakAtRune - i++ - } else { - break innerLoop - } - } - numRunes := end - start + 1 - outputs = append(outputs, output{ - Shaped: good, - RuneRange: text.Range{ - Count: numRunes, - Offset: runesProcessed, - }, - }) - runesProcessed += numRunes - start = end + 1 - } - return outputs -} - -// output is a run of shaped text with metadata about its position -// within a text document. -type output struct { - Shaped shaping.Output - RuneRange text.Range -} - -func toSystemDirection(d di.Direction) system.TextDirection { - switch d { - case di.DirectionLTR: - return system.LTR - case di.DirectionRTL: - return system.RTL - } - return system.LTR -} - -// toGioGlyphs converts text shaper glyphs into the minimal representation -// that Gio needs. -func toGioGlyphs(in []shaping.Glyph) []text.Glyph { - out := make([]text.Glyph, 0, len(in)) - for _, g := range in { - out = append(out, text.Glyph{ - ID: g.GlyphID, - ClusterIndex: g.ClusterIndex, - RuneCount: g.RuneCount, - GlyphCount: g.GlyphCount, - XAdvance: g.XAdvance, - YAdvance: g.YAdvance, - XOffset: g.XOffset, - YOffset: g.YOffset, - }) - } - return out -} - -// ToLine converts the output into a text.Line -func (o output) ToLine() text.Line { - layout := text.Layout{ - Glyphs: toGioGlyphs(o.Shaped.Glyphs), - Runes: o.RuneRange, - Direction: toSystemDirection(o.Shaped.Direction), - } - return text.Line{ - Layout: layout, - Bounds: fixed.Rectangle26_6{ - Min: fixed.Point26_6{ - Y: -o.Shaped.LineBounds.Ascent, - }, - Max: fixed.Point26_6{ - X: o.Shaped.Advance, - Y: -o.Shaped.LineBounds.Ascent + o.Shaped.LineBounds.LineHeight(), - }, - }, - Width: o.Shaped.Advance, - Ascent: o.Shaped.LineBounds.Ascent, - Descent: -o.Shaped.LineBounds.Descent + o.Shaped.LineBounds.Gap, - } -} - -func mapDirection(d system.TextDirection) di.Direction { - switch d { - case system.LTR: - return di.DirectionLTR - case system.RTL: - return di.DirectionRTL - } - return di.DirectionLTR -} - -// Document shapes text using the given font, ppem, maximum line width, language, -// and sequence of runes. It returns a slice of lines corresponding to the txt, -// broken to fit within maxWidth and on paragraph boundaries. -func Document(shaper Shaper, face font.Face, ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) []text.Line { - var ( - outputs []text.Line - startByte int - startRune int - paragraphText []rune - done bool - langs = make(map[language.Script]int) - ) - for !done { - var ( - bytes int - runes int - ) - newlineAdjust := 0 - paragraphLoop: - for r, sz, re := txt.ReadRune(); !done; r, sz, re = txt.ReadRune() { - if re != nil { - done = true - continue - } - paragraphText = append(paragraphText, r) - script := language.LookupScript(r) - langs[script]++ - bytes += sz - runes++ - if r == '\n' { - newlineAdjust = 1 - break paragraphLoop - } - } - var ( - primary language.Script - primaryTotal int - ) - for script, total := range langs { - if total > primaryTotal { - primary = script - primaryTotal = total - } - } - if lc.Language == "" { - lc.Language = "EN" - } - lcfg := langConfig{ - Language: language.NewLanguage(lc.Language), - Script: primary, - Direction: mapDirection(lc.Direction), - } - lines, _ := paragraph(shaper, face, ppem, maxWidth, lcfg, paragraphText[:len(paragraphText)-newlineAdjust]) - for i := range lines { - // Update the offsets of each paragraph to be correct within the - // whole document. - lines[i].RuneRange.Offset += startRune - // Update the cluster values to be rune indices within the entire - // document. - for k := range lines[i].Shaped.Glyphs { - lines[i].Shaped.Glyphs[k].ClusterIndex += startRune - } - outputs = append(outputs, lines[i].ToLine()) - } - // If there was a trailing newline update the byte counts to include - // it on the last line of the paragraph. - if newlineAdjust > 0 { - outputs[len(outputs)-1].Layout.Runes.Count += newlineAdjust - } - paragraphText = paragraphText[:0] - startByte += bytes - startRune += runes - } - for i := range outputs { - computeGlyphClusters(&outputs[i].Layout) - } - return outputs -} - -// toInput converts its parameters into a shaping.Input. -func toInput(face font.Face, ppem fixed.Int26_6, lc langConfig, runes []rune) shaping.Input { - var input shaping.Input - input.Direction = lc.Direction - input.Text = runes - input.Size = ppem - input.Face = face - input.Language = lc.Language - input.Script = lc.Script - input.RunStart = 0 - input.RunEnd = len(runes) - return input -} diff --git a/font/opentype/internal/shaping_test.go b/font/opentype/internal/shaping_test.go deleted file mode 100644 index ecbd89e0..00000000 --- a/font/opentype/internal/shaping_test.go +++ /dev/null @@ -1,1522 +0,0 @@ -package internal - -import ( - "bytes" - "reflect" - "sort" - "testing" - "testing/quick" - - "gioui.org/io/system" - "gioui.org/text" - "github.com/go-text/typesetting/di" - "github.com/go-text/typesetting/shaping" - "golang.org/x/image/math/fixed" -) - -// glyph returns a glyph with the given cluster. Its dimensions -// are a square sitting atop the baseline, with 10 units to a side. -func glyph(cluster int) shaping.Glyph { - return shaping.Glyph{ - XAdvance: fixed.I(10), - YAdvance: fixed.I(10), - Width: fixed.I(10), - Height: fixed.I(10), - YBearing: fixed.I(10), - ClusterIndex: cluster, - } -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// glyphs returns a slice of glyphs with clusters from start to -// end. If start is greater than end, the glyphs will be returned -// with descending cluster values. -func glyphs(start, end int) []shaping.Glyph { - inc := 1 - if start > end { - inc = -inc - } - num := max(start, end) - min(start, end) + 1 - g := make([]shaping.Glyph, 0, num) - for i := start; i >= 0 && i <= max(start, end); i += inc { - g = append(g, glyph(i)) - } - return g -} - -func TestMapRunesToClusterIndices(t *testing.T) { - type testcase struct { - name string - runes []rune - glyphs []shaping.Glyph - expected []int - } - for _, tc := range []testcase{ - { - name: "simple", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(0), - glyph(1), - glyph(2), - glyph(3), - glyph(4), - }, - expected: []int{0, 1, 2, 3, 4}, - }, - { - name: "simple rtl", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(4), - glyph(3), - glyph(2), - glyph(1), - glyph(0), - }, - expected: []int{4, 3, 2, 1, 0}, - }, - { - name: "fused clusters", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(0), - glyph(0), - glyph(2), - glyph(3), - glyph(3), - }, - expected: []int{0, 0, 2, 3, 3}, - }, - { - name: "fused clusters rtl", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(3), - glyph(3), - glyph(2), - glyph(0), - glyph(0), - }, - expected: []int{3, 3, 2, 0, 0}, - }, - { - name: "ligatures", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(0), - glyph(2), - glyph(3), - }, - expected: []int{0, 0, 1, 2, 2}, - }, - { - name: "ligatures rtl", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(3), - glyph(2), - glyph(0), - }, - expected: []int{2, 2, 1, 0, 0}, - }, - { - name: "expansion", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(0), - glyph(1), - glyph(1), - glyph(1), - glyph(2), - glyph(3), - glyph(4), - }, - expected: []int{0, 1, 4, 5, 6}, - }, - { - name: "expansion rtl", - runes: make([]rune, 5), - glyphs: []shaping.Glyph{ - glyph(4), - glyph(3), - glyph(2), - glyph(1), - glyph(1), - glyph(1), - glyph(0), - }, - expected: []int{6, 3, 2, 1, 0}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - mapping := mapRunesToClusterIndices(tc.runes, tc.glyphs) - if !reflect.DeepEqual(tc.expected, mapping) { - t.Errorf("expected %v, got %v", tc.expected, mapping) - } - }) - } -} - -func TestInclusiveRange(t *testing.T) { - type testcase struct { - name string - // inputs - start int - breakAfter int - runeToGlyph []int - numGlyphs int - // expected outputs - gs, ge int - } - for _, tc := range []testcase{ - { - name: "simple at start", - numGlyphs: 5, - start: 0, - breakAfter: 2, - runeToGlyph: []int{0, 1, 2, 3, 4}, - gs: 0, - ge: 2, - }, - { - name: "simple in middle", - numGlyphs: 5, - start: 1, - breakAfter: 3, - runeToGlyph: []int{0, 1, 2, 3, 4}, - gs: 1, - ge: 3, - }, - { - name: "simple at end", - numGlyphs: 5, - start: 2, - breakAfter: 4, - runeToGlyph: []int{0, 1, 2, 3, 4}, - gs: 2, - ge: 4, - }, - { - name: "simple at start rtl", - numGlyphs: 5, - start: 0, - breakAfter: 2, - runeToGlyph: []int{4, 3, 2, 1, 0}, - gs: 2, - ge: 4, - }, - { - name: "simple in middle rtl", - numGlyphs: 5, - start: 1, - breakAfter: 3, - runeToGlyph: []int{4, 3, 2, 1, 0}, - gs: 1, - ge: 3, - }, - { - name: "simple at end rtl", - numGlyphs: 5, - start: 2, - breakAfter: 4, - runeToGlyph: []int{4, 3, 2, 1, 0}, - gs: 0, - ge: 2, - }, - { - name: "fused clusters at start", - numGlyphs: 5, - start: 0, - breakAfter: 1, - runeToGlyph: []int{0, 0, 2, 3, 3}, - gs: 0, - ge: 1, - }, - { - name: "fused clusters start and middle", - numGlyphs: 5, - start: 0, - breakAfter: 2, - runeToGlyph: []int{0, 0, 2, 3, 3}, - gs: 0, - ge: 2, - }, - { - name: "fused clusters middle and end", - numGlyphs: 5, - start: 2, - breakAfter: 4, - runeToGlyph: []int{0, 0, 2, 3, 3}, - gs: 2, - ge: 4, - }, - { - name: "fused clusters at end", - numGlyphs: 5, - start: 3, - breakAfter: 4, - runeToGlyph: []int{0, 0, 2, 3, 3}, - gs: 3, - ge: 4, - }, - { - name: "fused clusters at start rtl", - numGlyphs: 5, - start: 0, - breakAfter: 1, - runeToGlyph: []int{3, 3, 2, 0, 0}, - gs: 3, - ge: 4, - }, - { - name: "fused clusters start and middle rtl", - numGlyphs: 5, - start: 0, - breakAfter: 2, - runeToGlyph: []int{3, 3, 2, 0, 0}, - gs: 2, - ge: 4, - }, - { - name: "fused clusters middle and end rtl", - numGlyphs: 5, - start: 2, - breakAfter: 4, - runeToGlyph: []int{3, 3, 2, 0, 0}, - gs: 0, - ge: 2, - }, - { - name: "fused clusters at end rtl", - numGlyphs: 5, - start: 3, - breakAfter: 4, - runeToGlyph: []int{3, 3, 2, 0, 0}, - gs: 0, - ge: 1, - }, - { - name: "ligatures at start", - numGlyphs: 3, - start: 0, - breakAfter: 2, - runeToGlyph: []int{0, 0, 1, 2, 2}, - gs: 0, - ge: 1, - }, - { - name: "ligatures in middle", - numGlyphs: 3, - start: 2, - breakAfter: 2, - runeToGlyph: []int{0, 0, 1, 2, 2}, - gs: 1, - ge: 1, - }, - { - name: "ligatures at end", - numGlyphs: 3, - start: 2, - breakAfter: 4, - runeToGlyph: []int{0, 0, 1, 2, 2}, - gs: 1, - ge: 2, - }, - { - name: "ligatures at start rtl", - numGlyphs: 3, - start: 0, - breakAfter: 2, - runeToGlyph: []int{2, 2, 1, 0, 0}, - gs: 1, - ge: 2, - }, - { - name: "ligatures in middle rtl", - numGlyphs: 3, - start: 2, - breakAfter: 2, - runeToGlyph: []int{2, 2, 1, 0, 0}, - gs: 1, - ge: 1, - }, - { - name: "ligatures at end rtl", - numGlyphs: 3, - start: 2, - breakAfter: 4, - runeToGlyph: []int{2, 2, 1, 0, 0}, - gs: 0, - ge: 1, - }, - { - name: "expansion at start", - numGlyphs: 7, - start: 0, - breakAfter: 2, - runeToGlyph: []int{0, 1, 4, 5, 6}, - gs: 0, - ge: 4, - }, - { - name: "expansion in middle", - numGlyphs: 7, - start: 1, - breakAfter: 3, - runeToGlyph: []int{0, 1, 4, 5, 6}, - gs: 1, - ge: 5, - }, - { - name: "expansion at end", - numGlyphs: 7, - start: 2, - breakAfter: 4, - runeToGlyph: []int{0, 1, 4, 5, 6}, - gs: 4, - ge: 6, - }, - { - name: "expansion at start rtl", - numGlyphs: 7, - start: 0, - breakAfter: 2, - runeToGlyph: []int{6, 3, 2, 1, 0}, - gs: 2, - ge: 6, - }, - { - name: "expansion in middle rtl", - numGlyphs: 7, - start: 1, - breakAfter: 3, - runeToGlyph: []int{6, 3, 2, 1, 0}, - gs: 1, - ge: 5, - }, - { - name: "expansion at end rtl", - numGlyphs: 7, - start: 2, - breakAfter: 4, - runeToGlyph: []int{6, 3, 2, 1, 0}, - gs: 0, - ge: 2, - }, - } { - t.Run(tc.name, func(t *testing.T) { - gs, ge := inclusiveGlyphRange(tc.start, tc.breakAfter, tc.runeToGlyph, tc.numGlyphs) - if gs != tc.gs { - t.Errorf("glyphStart mismatch, got %d, expected %d", gs, tc.gs) - } - if ge != tc.ge { - t.Errorf("glyphEnd mismatch, got %d, expected %d", ge, tc.ge) - } - }) - } -} - -var ( - // Assume the simple case of 1:1:1 glyph:rune:byte for this input. - text1 = "text one is ltr" - shapedText1 = shaping.Output{ - Advance: fixed.I(10 * len([]rune(text1))), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - // No line gap. - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - // No glyphs descend. - }, - Glyphs: glyphs(0, 14), - } - text1Trailing = text1 + " " - shapedText1Trailing = func() shaping.Output { - out := shapedText1 - out.Glyphs = append(out.Glyphs, glyph(len(out.Glyphs))) - out.RecalculateAll() - return out - }() - // Test M:N:O glyph:rune:byte for this input. - // The substring `lig` is shaped as a ligature. - // The substring `DROP` is not shaped at all. - text2 = "안П你 ligDROP 안П你 ligDROP" - shapedText2 = shaping.Output{ - // There are 11 glyphs shaped for this string. - Advance: fixed.I(10 * 11), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - // No line gap. - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - // No glyphs descend. - }, - Glyphs: []shaping.Glyph{ - 0: glyph(0), // 안 - 4 bytes - 1: glyph(1), // П - 3 bytes - 2: glyph(2), // 你 - 4 bytes - 3: glyph(3), // - 1 byte - 4: glyph(4), // lig - 3 runes, 3 bytes - // DROP - 4 runes, 4 bytes - 5: glyph(11), // - 1 byte - 6: glyph(12), // 안 - 4 bytes - 7: glyph(13), // П - 3 bytes - 8: glyph(14), // 你 - 4 bytes - 9: glyph(15), // - 1 byte - 10: glyph(16), // lig - 3 runes, 3 bytes - // DROP - 4 runes, 4 bytes - }, - } - // Test RTL languages. - text3 = "שלום أهلا שלום أهلا" - shapedText3 = shaping.Output{ - // There are 15 glyphs shaped for this string. - Advance: fixed.I(10 * 15), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - // No line gap. - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - // No glyphs descend. - }, - Glyphs: []shaping.Glyph{ - 0: glyph(16), // LIGATURE of three runes: - // ا - 3 bytes - // ل - 3 bytes - // ه - 3 bytes - 1: glyph(15), // أ - 3 bytes - 2: glyph(14), // - 1 byte - 3: glyph(13), // ם - 3 bytes - 4: glyph(12), // ו - 3 bytes - 5: glyph(11), // ל - 3 bytes - 6: glyph(10), // ש - 3 bytes - 7: glyph(9), // - 1 byte - 8: glyph(6), // LIGATURE of three runes: - // ا - 3 bytes - // ل - 3 bytes - // ه - 3 bytes - 9: glyph(5), // أ - 3 bytes - 10: glyph(4), // - 1 byte - 11: glyph(3), // ם - 3 bytes - 12: glyph(2), // ו - 3 bytes - 13: glyph(1), // ל - 3 bytes - 14: glyph(0), // ש - 3 bytes - }, - } -) - -// splitShapedAt splits a single shaped output into multiple. It splits -// on each provided glyph index in indices, with the index being the end of -// a slice range (so it's exclusive). You can think of the index as the -// first glyph of the next output. -func splitShapedAt(shaped shaping.Output, direction di.Direction, indices ...int) []shaping.Output { - numOut := len(indices) + 1 - outputs := make([]shaping.Output, 0, numOut) - start := 0 - for _, i := range indices { - newOut := shaped - newOut.Glyphs = newOut.Glyphs[start:i] - newOut.RecalculateAll() - outputs = append(outputs, newOut) - start = i - } - newOut := shaped - newOut.Glyphs = newOut.Glyphs[start:] - newOut.RecalculateAll() - outputs = append(outputs, newOut) - return outputs -} - -func TestEngineLineWrap(t *testing.T) { - type testcase struct { - name string - direction di.Direction - shaped shaping.Output - paragraph []rune - maxWidth int - expected []output - } - for _, tc := range []testcase{ - { - // This test case verifies that no line breaks occur if they are not - // necessary, and that the proper Offsets are reported in the output. - name: "all one line", - shaped: shapedText1, - direction: di.DirectionLTR, - paragraph: []rune(text1), - maxWidth: 1000, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune(text1)), - }, - Shaped: shapedText1, - }, - }, - }, - { - // This test case verifies that trailing whitespace characters on a - // line do not just disappear if it's the first line. - name: "trailing whitespace", - shaped: shapedText1Trailing, - direction: di.DirectionLTR, - paragraph: []rune(text1Trailing), - maxWidth: 1000, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune(text1)) + 1, - }, - Shaped: shapedText1Trailing, - }, - }, - }, - { - // This test case verifies that the line wrapper rejects line break - // candidates that would split a glyph cluster. - name: "reject mid-cluster line breaks", - shaped: shaping.Output{ - Advance: fixed.I(10 * 3), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - // No line gap. - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - // No glyphs descend. - }, - Glyphs: []shaping.Glyph{ - simpleGlyph(0), - complexGlyph(1, 2, 2), - complexGlyph(1, 2, 2), - }, - }, - direction: di.DirectionLTR, - // This unicode data was discovered in a testing/quick failure - // for widget.Editor. It has the property that the middle two - // runes form a harfbuzz cluster but also have a legal UAX#14 - // segment break between them. - paragraph: []rune{0xa8e58, 0x3a4fd, 0x119dd}, - maxWidth: 20, - expected: []output{ - { - RuneRange: text.Range{ - Count: 1, - }, - Shaped: shaping.Output{ - Direction: di.DirectionLTR, - Advance: fixed.I(10), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - }, - Glyphs: []shaping.Glyph{ - simpleGlyph(0), - }, - }, - }, - { - RuneRange: text.Range{ - Count: 2, - Offset: 1, - }, - Shaped: shaping.Output{ - Direction: di.DirectionLTR, - Advance: fixed.I(20), - LineBounds: shaping.Bounds{ - Ascent: fixed.I(10), - Descent: fixed.I(5), - }, - GlyphBounds: shaping.Bounds{ - Ascent: fixed.I(10), - }, - Glyphs: []shaping.Glyph{ - complexGlyph(1, 2, 2), - complexGlyph(1, 2, 2), - }, - }, - }, - }, - }, - { - // This test case verifies that line breaking does occur, and that - // all lines have proper offsets. - name: "line break on last word", - shaped: shapedText1, - direction: di.DirectionLTR, - paragraph: []rune(text1), - maxWidth: 120, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune(text1)) - 3, - }, - Shaped: splitShapedAt(shapedText1, di.DirectionLTR, 12)[0], - }, - { - RuneRange: text.Range{ - Offset: len([]rune(text1)) - 3, - Count: 3, - }, - Shaped: splitShapedAt(shapedText1, di.DirectionLTR, 12)[1], - }, - }, - }, - { - // This test case verifies that many line breaks still result in - // correct offsets. This test also ensures that leading whitespace - // is correctly hidden on lines after the first. - name: "line break several times", - shaped: shapedText1, - direction: di.DirectionLTR, - paragraph: []rune(text1), - maxWidth: 70, - expected: []output{ - { - RuneRange: text.Range{ - Count: 5, - }, - Shaped: splitShapedAt(shapedText1, di.DirectionLTR, 5)[0], - }, - { - RuneRange: text.Range{ - Offset: 5, - Count: 7, - }, - Shaped: splitShapedAt(shapedText1, di.DirectionLTR, 5, 12)[1], - }, - { - RuneRange: text.Range{ - Offset: 12, - Count: 3, - }, - Shaped: splitShapedAt(shapedText1, di.DirectionLTR, 12)[1], - }, - }, - }, - { - // This test case verifies baseline offset math for more complicated input. - name: "all one line 2", - shaped: shapedText2, - direction: di.DirectionLTR, - paragraph: []rune(text2), - maxWidth: 1000, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune(text2)), - }, - Shaped: shapedText2, - }, - }, - }, - { - // This test case verifies that offset accounting correctly handles complex - // input across line breaks. It is legal to line-break within words composed - // of more than one script, so this test expects that to occur. - name: "line break several times 2", - shaped: shapedText2, - direction: di.DirectionLTR, - paragraph: []rune(text2), - maxWidth: 40, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune("안П你 ")), - }, - Shaped: splitShapedAt(shapedText2, di.DirectionLTR, 4)[0], - }, - { - RuneRange: text.Range{ - Count: len([]rune("ligDROP 안П")), - Offset: len([]rune("안П你 ")), - }, - Shaped: splitShapedAt(shapedText2, di.DirectionLTR, 4, 8)[1], - }, - { - RuneRange: text.Range{ - Count: len([]rune("你 ligDROP")), - Offset: len([]rune("안П你 ligDROP 안П")), - }, - Shaped: splitShapedAt(shapedText2, di.DirectionLTR, 8, 11)[1], - }, - }, - }, - { - // This test case verifies baseline offset math for complex RTL input. - name: "all one line 3", - shaped: shapedText3, - direction: di.DirectionLTR, - paragraph: []rune(text3), - maxWidth: 1000, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune(text3)), - }, - Shaped: shapedText3, - }, - }, - }, - { - // This test case verifies line wrapping logic in RTL mode. - name: "line break once [RTL]", - shaped: shapedText3, - direction: di.DirectionRTL, - paragraph: []rune(text3), - maxWidth: 100, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune("שלום أهلا ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 7)[1], - }, - { - RuneRange: text.Range{ - Count: len([]rune("שלום أهلا")), - Offset: len([]rune("שלום أهلا ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 7)[0], - }, - }, - }, - { - // This test case verifies line wrapping logic in RTL mode. - name: "line break several times [RTL]", - shaped: shapedText3, - direction: di.DirectionRTL, - paragraph: []rune(text3), - maxWidth: 50, - expected: []output{ - { - RuneRange: text.Range{ - Count: len([]rune("שלום ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 10)[1], - }, - { - RuneRange: text.Range{ - Count: len([]rune("أهلا ")), - Offset: len([]rune("שלום ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 7, 10)[1], - }, - { - RuneRange: text.Range{ - Count: len([]rune("שלום ")), - Offset: len([]rune("שלום أهلا ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 2, 7)[1], - }, - { - RuneRange: text.Range{ - Count: len([]rune("أهلا")), - Offset: len([]rune("שלום أهلا שלום ")), - }, - Shaped: splitShapedAt(shapedText3, di.DirectionRTL, 2)[0], - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - // Get a mapping from input runes to output glyphs. - runeToGlyph := mapRunesToClusterIndices(tc.paragraph, tc.shaped.Glyphs) - - // Fetch line break candidates. - breaks := getBreakOptions(tc.paragraph) - - outs := lineWrap(tc.shaped, tc.direction, tc.paragraph, runeToGlyph, breaks, tc.maxWidth) - if len(tc.expected) != len(outs) { - t.Errorf("expected %d lines, got %d", len(tc.expected), len(outs)) - } - for i := range tc.expected { - e := tc.expected[i] - o := outs[i] - lenE := len(e.Shaped.Glyphs) - lenO := len(o.Shaped.Glyphs) - if lenE != lenO { - t.Errorf("line %d: expected %d glyphs, got %d", i, lenE, lenO) - } else { - for k := range e.Shaped.Glyphs { - e := e.Shaped.Glyphs[k] - o := o.Shaped.Glyphs[k] - if !reflect.DeepEqual(e, o) { - t.Errorf("line %d: glyph mismatch at index %d, expected: %#v, got %#v", i, k, e, o) - } - } - } - if e.RuneRange != o.RuneRange { - t.Errorf("line %d: expected %#v offsets, got %#v", i, e.RuneRange, o.RuneRange) - } - if e.Shaped.Direction != o.Shaped.Direction { - t.Errorf("line %d: expected %v direction, got %v", i, e.Shaped.Direction, o.Shaped.Direction) - } - // Reduce the verbosity of the reflect mismatch since we already - // compared the glyphs. - e.Shaped.Glyphs = nil - o.Shaped.Glyphs = nil - if !reflect.DeepEqual(e.Shaped, o.Shaped) { - t.Errorf("line %d: expected: %#v, got %#v", i, e, o) - } - } - }) - } -} - -func TestEngineDocument(t *testing.T) { - const doc = `Rutrum quisque non tellus orci ac auctor augue. -At risus viverra adipiscing at.` - english := system.Locale{ - Language: "EN", - Direction: system.LTR, - } - docRunes := len([]rune(doc)) - - // Override the shaping engine with one that will return a simple - // square glyph info for each rune in the input. - shaper := func(in shaping.Input) (shaping.Output, error) { - o := shaping.Output{ - // TODO: ensure that this is either inclusive or exclusive - Glyphs: glyphs(in.RunStart, in.RunEnd), - } - o.RecalculateAll() - return o, nil - } - - lines := Document(shaper, nil, 10, 100, english, bytes.NewBufferString(doc)) - - lineRunes := 0 - for i, line := range lines { - t.Logf("Line %d: runeOffset %d, runes %d", - i, line.Layout.Runes.Offset, line.Layout.Runes.Count) - if line.Layout.Runes.Offset != lineRunes { - t.Errorf("expected line %d to start at byte %d, got %d", i, lineRunes, line.Layout.Runes.Offset) - } - lineRunes += line.Layout.Runes.Count - } - if lineRunes != docRunes { - t.Errorf("unexpected count: expected %d runes, got %d runes", - docRunes, lineRunes) - } -} - -// simpleGlyph returns a simple square glyph with the provided cluster -// value. -func simpleGlyph(cluster int) shaping.Glyph { - return complexGlyph(cluster, 1, 1) -} - -// ligatureGlyph returns a simple square glyph with the provided cluster -// value and number of runes. -func ligatureGlyph(cluster, runes int) shaping.Glyph { - return complexGlyph(cluster, runes, 1) -} - -// expansionGlyph returns a simple square glyph with the provided cluster -// value and number of glyphs. -func expansionGlyph(cluster, glyphs int) shaping.Glyph { - return complexGlyph(cluster, 1, glyphs) -} - -// complexGlyph returns a simple square glyph with the provided cluster -// value, number of associated runes, and number of glyphs in the cluster. -func complexGlyph(cluster, runes, glyphs int) shaping.Glyph { - return shaping.Glyph{ - Width: fixed.I(10), - Height: fixed.I(10), - XAdvance: fixed.I(10), - YAdvance: fixed.I(10), - YBearing: fixed.I(10), - ClusterIndex: cluster, - GlyphCount: glyphs, - RuneCount: runes, - } -} - -func simpleCluster(runeOffset, glyphOffset int, ltr bool) text.GlyphCluster { - g := text.GlyphCluster{ - Advance: fixed.I(10), - Runes: text.Range{ - Count: 1, - Offset: runeOffset, - }, - Glyphs: text.Range{ - Count: 1, - Offset: glyphOffset, - }, - } - if !ltr { - g.Advance = -g.Advance - } - return g -} - -func TestLayoutComputeClusters(t *testing.T) { - type testcase struct { - name string - line text.Layout - expected []text.GlyphCluster - } - for _, tc := range []testcase{ - { - name: "empty", - expected: []text.GlyphCluster{}, - }, - { - name: "just newline", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{}), - Runes: text.Range{ - Count: 1, - }, - }, - expected: []text.GlyphCluster{ - { - Runes: text.Range{ - Count: 1, - }, - }, - }, - }, - { - name: "simple", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(0), - simpleGlyph(1), - simpleGlyph(2), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 0, true), - simpleCluster(1, 1, true), - simpleCluster(2, 2, true), - }, - }, - { - name: "simple with newline", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(0), - simpleGlyph(1), - simpleGlyph(2), - }), - Runes: text.Range{ - Count: 4, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 0, true), - simpleCluster(1, 1, true), - simpleCluster(2, 2, true), - { - Runes: text.Range{ - Count: 1, - Offset: 3, - }, - Glyphs: text.Range{ - Offset: 3, - }, - }, - }, - }, - { - name: "ligature", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - ligatureGlyph(0, 2), - simpleGlyph(2), - simpleGlyph(3), - }), - Runes: text.Range{ - Count: 4, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(10), - Runes: text.Range{ - Count: 2, - }, - Glyphs: text.Range{ - Count: 1, - }, - }, - simpleCluster(2, 1, true), - simpleCluster(3, 2, true), - }, - }, - { - name: "ligature with newline", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - ligatureGlyph(0, 2), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(10), - Runes: text.Range{ - Count: 2, - }, - Glyphs: text.Range{ - Count: 1, - }, - }, - { - Runes: text.Range{ - Count: 1, - Offset: 2, - }, - Glyphs: text.Range{ - Offset: 1, - }, - }, - }, - }, - { - name: "expansion", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - expansionGlyph(0, 2), - expansionGlyph(0, 2), - simpleGlyph(1), - simpleGlyph(2), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(20), - Runes: text.Range{ - Count: 1, - }, - Glyphs: text.Range{ - Count: 2, - }, - }, - simpleCluster(1, 2, true), - simpleCluster(2, 3, true), - }, - }, - { - name: "deletion", - line: text.Layout{ - Direction: system.LTR, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(0), - ligatureGlyph(1, 2), - simpleGlyph(3), - simpleGlyph(4), - }), - Runes: text.Range{ - Count: 5, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 0, true), - { - Advance: fixed.I(10), - Runes: text.Range{ - Count: 2, - Offset: 1, - }, - Glyphs: text.Range{ - Count: 1, - Offset: 1, - }, - }, - simpleCluster(3, 2, true), - simpleCluster(4, 3, true), - }, - }, - { - name: "simple rtl", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(2), - simpleGlyph(1), - simpleGlyph(0), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 2, false), - simpleCluster(1, 1, false), - simpleCluster(2, 0, false), - }, - }, - { - name: "simple rtl with newline", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(2), - simpleGlyph(1), - simpleGlyph(0), - }), - Runes: text.Range{ - Count: 4, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 2, false), - simpleCluster(1, 1, false), - simpleCluster(2, 0, false), - { - Runes: text.Range{ - Count: 1, - Offset: 3, - }, - }, - }, - }, - { - name: "ligature rtl", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(3), - simpleGlyph(2), - ligatureGlyph(0, 2), - }), - Runes: text.Range{ - Count: 4, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(-10), - Runes: text.Range{ - Count: 2, - }, - Glyphs: text.Range{ - Count: 1, - Offset: 2, - }, - }, - simpleCluster(2, 1, false), - simpleCluster(3, 0, false), - }, - }, - { - name: "ligature rtl with newline", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - ligatureGlyph(0, 2), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(-10), - Runes: text.Range{ - Count: 2, - }, - Glyphs: text.Range{ - Count: 1, - }, - }, - { - Runes: text.Range{ - Count: 1, - Offset: 2, - }, - }, - }, - }, - { - name: "expansion rtl", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(2), - simpleGlyph(1), - expansionGlyph(0, 2), - expansionGlyph(0, 2), - }), - Runes: text.Range{ - Count: 3, - }, - }, - expected: []text.GlyphCluster{ - { - Advance: fixed.I(-20), - Runes: text.Range{ - Count: 1, - }, - Glyphs: text.Range{ - Count: 2, - Offset: 2, - }, - }, - simpleCluster(1, 1, false), - simpleCluster(2, 0, false), - }, - }, - { - name: "deletion rtl", - line: text.Layout{ - Direction: system.RTL, - Glyphs: toGioGlyphs([]shaping.Glyph{ - simpleGlyph(4), - simpleGlyph(3), - ligatureGlyph(1, 2), - simpleGlyph(0), - }), - Runes: text.Range{ - Count: 5, - }, - }, - expected: []text.GlyphCluster{ - simpleCluster(0, 3, false), - { - Advance: fixed.I(-10), - Runes: text.Range{ - Count: 2, - Offset: 1, - }, - Glyphs: text.Range{ - Count: 1, - Offset: 2, - }, - }, - simpleCluster(3, 1, false), - simpleCluster(4, 0, false), - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - computeGlyphClusters(&tc.line) - actual := tc.line.Clusters - if !reflect.DeepEqual(actual, tc.expected) { - t.Errorf("expected %v, got %v", tc.expected, actual) - } - }) - } -} - -func TestGetBreakOptions(t *testing.T) { - if err := quick.Check(func(runes []rune) bool { - options := getBreakOptions(runes) - // Ensure breaks are in valid range. - for _, o := range options { - if o.breakAtRune < 0 || o.breakAtRune > len(runes)-1 { - return false - } - } - // Ensure breaks are sorted. - if !sort.SliceIsSorted(options, func(i, j int) bool { - return options[i].breakAtRune < options[j].breakAtRune - }) { - return false - } - - // Ensure breaks are unique. - m := make([]bool, len(runes)) - for _, o := range options { - if m[o.breakAtRune] { - return false - } else { - m[o.breakAtRune] = true - } - } - - return true - }, nil); err != nil { - t.Errorf("generated invalid break options: %v", err) - } -} - -func TestLayoutSlice(t *testing.T) { - type testcase struct { - name string - in text.Layout - expected text.Layout - start, end int - } - - ltrGlyphs := toGioGlyphs([]shaping.Glyph{ - simpleGlyph(0), - complexGlyph(1, 2, 2), - complexGlyph(1, 2, 2), - simpleGlyph(3), - simpleGlyph(4), - simpleGlyph(5), - ligatureGlyph(6, 3), - simpleGlyph(9), - simpleGlyph(10), - }) - rtlGlyphs := toGioGlyphs([]shaping.Glyph{ - simpleGlyph(10), - simpleGlyph(9), - ligatureGlyph(6, 3), - simpleGlyph(5), - simpleGlyph(4), - simpleGlyph(3), - complexGlyph(1, 2, 2), - complexGlyph(1, 2, 2), - simpleGlyph(0), - }) - - for _, tc := range []testcase{ - { - name: "ltr", - in: func() text.Layout { - l := text.Layout{ - Glyphs: ltrGlyphs, - Direction: system.LTR, - Runes: text.Range{ - Count: 11, - }, - } - computeGlyphClusters(&l) - return l - }(), - expected: func() text.Layout { - l := text.Layout{ - Glyphs: ltrGlyphs[5:], - Direction: system.LTR, - Runes: text.Range{ - Count: 6, - Offset: 5, - }, - } - return l - }(), - start: 4, - end: 8, - }, - { - name: "ltr different range", - in: func() text.Layout { - l := text.Layout{ - Glyphs: ltrGlyphs, - Direction: system.LTR, - Runes: text.Range{ - Count: 11, - }, - } - computeGlyphClusters(&l) - return l - }(), - expected: func() text.Layout { - l := text.Layout{ - Glyphs: ltrGlyphs[3:7], - Direction: system.LTR, - Runes: text.Range{ - Count: 6, - Offset: 3, - }, - } - return l - }(), - start: 2, - end: 6, - }, - { - name: "ltr zero len", - in: func() text.Layout { - l := text.Layout{ - Glyphs: ltrGlyphs, - Direction: system.LTR, - Runes: text.Range{ - Count: 11, - }, - } - computeGlyphClusters(&l) - return l - }(), - expected: text.Layout{}, - start: 0, - end: 0, - }, - { - name: "rtl", - in: func() text.Layout { - l := text.Layout{ - Glyphs: rtlGlyphs, - Direction: system.RTL, - Runes: text.Range{ - Count: 11, - }, - } - computeGlyphClusters(&l) - return l - }(), - expected: func() text.Layout { - l := text.Layout{ - Glyphs: rtlGlyphs[:4], - Direction: system.RTL, - Runes: text.Range{ - Count: 6, - Offset: 5, - }, - } - return l - }(), - start: 4, - end: 8, - }, - } { - t.Run(tc.name, func(t *testing.T) { - out := tc.in.Slice(tc.start, tc.end) - if len(out.Glyphs) != len(tc.expected.Glyphs) { - t.Errorf("expected %v glyphs, got %v", len(tc.expected.Glyphs), len(out.Glyphs)) - } - if len(out.Clusters) != len(tc.expected.Clusters) { - t.Errorf("expected %v clusters, got %v", len(tc.expected.Clusters), len(out.Clusters)) - } - if out.Runes != tc.expected.Runes { - t.Errorf("expected %#+v, got %#+v", tc.expected.Runes, out.Runes) - } - if out.Direction != tc.expected.Direction { - t.Errorf("expected %#+v, got %#+v", tc.expected.Direction, out.Direction) - } - }) - } -} diff --git a/font/opentype/opentype.go b/font/opentype/opentype.go index 87d76507..e4f4aa56 100644 --- a/font/opentype/opentype.go +++ b/font/opentype/opentype.go @@ -7,132 +7,25 @@ package opentype import ( "bytes" "fmt" - "image" - "io" - "github.com/benoitkugler/textlayout/fonts" "github.com/benoitkugler/textlayout/fonts/truetype" - "github.com/benoitkugler/textlayout/harfbuzz" - "github.com/go-text/typesetting/shaping" - "golang.org/x/image/font" - "golang.org/x/image/math/fixed" - - "gioui.org/f32" - "gioui.org/font/opentype/internal" - "gioui.org/io/system" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/text" + "github.com/go-text/typesetting/font" ) -// Font implements the text.Shaper interface using a rich text -// shaping engine. -type Font struct { - font *truetype.Font +// Face is a shapeable representation of a font. +type Face struct { + face font.Face } -// Parse constructs a Font from source bytes. -func Parse(src []byte) (*Font, error) { +// Parse constructs a Face from source bytes. +func Parse(src []byte) (Face, error) { face, err := truetype.Parse(bytes.NewReader(src)) if err != nil { - return nil, fmt.Errorf("failed parsing truetype font: %w", err) + return Face{}, fmt.Errorf("failed parsing truetype font: %w", err) } - return &Font{ - font: face, - }, nil + return Face{face: face}, nil } -func (f *Font) Layout(ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) ([]text.Line, error) { - return internal.Document(shaping.Shape, f.font, ppem, maxWidth, lc, txt), nil -} - -func (f *Font) Shape(ppem fixed.Int26_6, str text.Layout) clip.PathSpec { - return textPath(ppem, f, str) -} - -func (f *Font) Metrics(ppem fixed.Int26_6) font.Metrics { - metrics := font.Metrics{} - font := harfbuzz.NewFont(f.font) - font.XScale = int32(ppem.Ceil()) << 6 - font.YScale = font.XScale - // Use any horizontal direction. - fontExtents := font.ExtentsForDirection(harfbuzz.LeftToRight) - ascender := fixed.I(int(fontExtents.Ascender * 64)) - descender := fixed.I(int(fontExtents.Descender * 64)) - gap := fixed.I(int(fontExtents.LineGap * 64)) - metrics.Height = ascender + descender + gap - metrics.Ascent = ascender - metrics.Descent = descender - // These three are not readily available. - // TODO(whereswaldon): figure out how to get these values. - metrics.XHeight = ascender - metrics.CapHeight = ascender - metrics.CaretSlope = image.Pt(0, 1) - - return metrics -} - -func textPath(ppem fixed.Int26_6, font *Font, str text.Layout) clip.PathSpec { - var lastPos f32.Point - var builder clip.Path - ops := new(op.Ops) - var x fixed.Int26_6 - builder.Begin(ops) - rune := 0 - ppemInt := ppem.Round() - ppem16 := uint16(ppemInt) - scaleFactor := float32(ppemInt) / float32(font.font.Upem()) - for _, g := range str.Glyphs { - advance := g.XAdvance - outline, ok := font.font.GlyphData(g.ID, ppem16, ppem16).(fonts.GlyphOutline) - if !ok { - continue - } - // Move to glyph position. - pos := f32.Point{ - X: float32(x)/64 - float32(g.XOffset)/64, - Y: -float32(g.YOffset) / 64, - } - builder.Move(pos.Sub(lastPos)) - lastPos = pos - var lastArg f32.Point - - // Convert sfnt.Segments to relative segments. - for _, fseg := range outline.Segments { - nargs := 1 - switch fseg.Op { - case fonts.SegmentOpQuadTo: - nargs = 2 - case fonts.SegmentOpCubeTo: - nargs = 3 - } - var args [3]f32.Point - for i := 0; i < nargs; i++ { - a := f32.Point{ - X: fseg.Args[i].X * scaleFactor, - Y: -fseg.Args[i].Y * scaleFactor, - } - args[i] = a.Sub(lastArg) - if i == nargs-1 { - lastArg = a - } - } - switch fseg.Op { - case fonts.SegmentOpMoveTo: - builder.Move(args[0]) - case fonts.SegmentOpLineTo: - builder.Line(args[0]) - case fonts.SegmentOpQuadTo: - builder.Quad(args[0], args[1]) - case fonts.SegmentOpCubeTo: - builder.Cube(args[0], args[1], args[2]) - default: - panic("unsupported segment op") - } - } - lastPos = lastPos.Add(lastArg) - x += advance - rune++ - } - return builder.End() +func (f Face) Face() font.Face { + return f.face } diff --git a/font/opentype/opentype_test.go b/font/opentype/opentype_test.go deleted file mode 100644 index 95502302..00000000 --- a/font/opentype/opentype_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package opentype - -import ( - "strings" - "testing" - - "golang.org/x/image/font/gofont/goregular" - "golang.org/x/image/math/fixed" - - "gioui.org/io/system" -) - -var english = system.Locale{ - Language: "EN", - Direction: system.LTR, -} - -func TestEmptyString(t *testing.T) { - face, err := Parse(goregular.TTF) - if err != nil { - t.Fatal(err) - } - - ppem := fixed.I(200) - - lines, err := face.Layout(ppem, 2000, english, strings.NewReader("")) - if err != nil { - t.Fatal(err) - } - if len(lines) == 0 { - t.Fatalf("Layout returned no lines for empty string; expected 1") - } - l := lines[0] - exp := fixed.Rectangle26_6{ - Min: fixed.Point26_6{ - Y: fixed.Int26_6(-12094), - }, - Max: fixed.Point26_6{ - Y: fixed.Int26_6(2700), - }, - } - if got := l.Bounds; got != exp { - t.Errorf("got bounds %+v for empty string; expected %+v", got, exp) - } -} diff --git a/font/opentype/testdata/only1.ttf.gz b/font/opentype/testdata/only1.ttf.gz deleted file mode 100644 index 544159de47fc3ab52c79712a711f487d49003c7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 442 zcmV;r0Y&~FiwFP!00002|7?*zXcR#h#ed)I%-+TeUKVm7Shx!bLM)O?j98rE8uhe^ zpoQ4z5-&L+XBI9(?6$BGgrvA4q=}uKh=rAfg@vUa0UP1aKA@mYOm<~w{ZC#oAH%%& z<~Ixilu2-yn!h?ZeSWfe0CE<%wa|%ThIs(;7&yHYbz@)z)OEmHTE4&d>C4t5kPpDM zb}PDFIq_luyadj)+pXy6Wpe}g3XHWot7}KP(g)eh^UEs>5ffBFb^x~%t;L*_Ign#O za5w6-Z~@d*ejcxMSJTf-gUa~=%dXV$c5hI=S*d@c;^k&`F5M~S9#7Ne!ACFlE&xL) z3JH|f(<|V;N}K6LFSbp8OBLO6jLvEP(C7d(d;{jeQv|g6`6{EZ_j6IJRjNojcHIyRT(gv1V_7-&AG7 z@&hJ^bSOdShyHlzhk|Cm;UxQs;X4hTNK|Va#((d7&Y3ytT%8d|y!VRtc%3U=TLd#|5iKGn z2SRbk5sGT+=!_0IqoO7%MIyK;Tu5!iMS(=wDwhU95w81KSS&X+gF-YCKGx3vs33QMx=6IEJ3P^G5tJIhh;;WHz2Tp&2IU~o z*44c~IAPA71#uEM-cO~6VjX(r@d_a^Yh)~8ZQ(|fPX z526YPq%z}^dKphZtO0s@xX&a;Ge`q)Wz5MD8bt_%eZMheW|Fu7XLA}@70KHf4 zGZ43IPCcZpIxka{c@pXs;`Sk+*!dOqVHC$PfI_KLs~8TaI_JS$Ej!8b8S&K z!NRcj&BOrB&Q)pBR#9+Kf`%a8 zYSY%Tj9YQ*lBRohy>ilu%ed_x2Q{RvQv?6`>sv)zTWd>W!@n+nLrY~SSnaQpZlQ+0 zM#P8dp1G;(3lBbRJ>9xFmYrT)oRf{$mQM^W-kDt!`pf$>S?hB4)c5@Ji2%6^cHh5& zDEbBEh05a$@gs#&YN}%We2Bmvg@bPa P00960-Xf b { + return a + } + return b +} + +// A line contains the measurements of a line of text. +type line struct { + // runs contains sequences of shaped glyphs with common attributes. The order + // of runs is logical, meaning that the first run will contain the glyphs + // corresponding to the first runes of data in the original text. + runs []runLayout + // visualOrder is a slice of indices into Runs that describes the visual positions + // of each run of text. Iterating this slice and accessing Runs at each + // of the values stored in this slice traverses the runs in proper visual + // order from left to right. + visualOrder []int + // width is the width of the line. + width fixed.Int26_6 + // ascent is the height above the baseline. + ascent fixed.Int26_6 + // descent is the height below the baseline, including + // the line gap. + descent fixed.Int26_6 + // bounds is the visible bounds of the line. + bounds fixed.Rectangle26_6 + // direction is the dominant direction of the line. This direction will be + // used to align the text content of the line, but may not match the actual + // direction of the runs of text within the line (such as an RTL sentence + // within an LTR paragraph). + direction system.TextDirection + // runeCount is the number of text runes represented by this line's runs. + runeCount int + + xOffset fixed.Int26_6 + yOffset int +} + +// Range describes the position and quantity of a range of text elements +// within a larger slice. The unit is usually runes of unicode data or +// glyphs of shaped font data. +type Range struct { + // Count describes the number of items represented by the Range. + Count int + // Offset describes the start position of the represented + // items within a larger list. + Offset int +} + +// glyph contains the metadata needed to render a glyph. +type glyph struct { + // id is this glyph's identifier within the font it was shaped with. + id GlyphID + // clusterIndex is the identifier for the text shaping cluster that + // this glyph is part of. + clusterIndex int + // glyphCount is the number of glyphs in the same cluster as this glyph. + glyphCount int + // runeCount is the quantity of runes in the source text that this glyph + // corresponds to. + runeCount int + // xAdvance and yAdvance describe the distance the dot moves when + // laying out the glyph on the X or Y axis. + xAdvance, yAdvance fixed.Int26_6 + // xOffset and yOffset describe offsets from the dot that should be + // applied when rendering the glyph. + xOffset, yOffset fixed.Int26_6 + // bounds describes the visual bounding box of the glyph relative to + // its dot. + bounds fixed.Rectangle26_6 +} + +type runLayout struct { + // VisualPosition describes the relative position of this run of text within + // its line. It should be a valid index into the containing line's VisualOrder + // slice. + VisualPosition int + // X is the visual offset of the dot for the first glyph in this run + // relative to the beginning of the line. + X fixed.Int26_6 + // Glyphs are the actual font characters for the text. They are ordered + // from left to right regardless of the text direction of the underlying + // text. + Glyphs []glyph + // Runes describes the position of the text data this layout represents + // within the containing text.Line. + Runes Range + // Advance is the sum of the advances of all clusters in the Layout. + Advance fixed.Int26_6 + // PPEM is the pixels-per-em scale used to shape this run. + PPEM fixed.Int26_6 + // Direction is the layout direction of the glyphs. + Direction system.TextDirection + // face is the font face that the ID of each Glyph in the Layout refers to. + face font.Face +} + +// faceOrderer chooses the order in which faces should be applied to text. +type faceOrderer struct { + def Font + faceScratch []font.Face + fontDefaultOrder map[Font]int + defaultOrderedFonts []Font + faces map[Font]font.Face + faceToIndex map[font.Face]int + fonts []Font +} + +func (f *faceOrderer) insert(fnt Font, face font.Face) { + if len(f.fonts) == 0 { + f.def = fnt + } + if f.fontDefaultOrder == nil { + f.fontDefaultOrder = make(map[Font]int) + } + if f.faces == nil { + f.faces = make(map[Font]font.Face) + f.faceToIndex = make(map[font.Face]int) + } + f.fontDefaultOrder[fnt] = len(f.faceScratch) + f.defaultOrderedFonts = append(f.defaultOrderedFonts, fnt) + f.faceScratch = append(f.faceScratch, face) + f.fonts = append(f.fonts, fnt) + f.faces[fnt] = face + f.faceToIndex[face] = f.fontDefaultOrder[fnt] +} + +// resetFontOrder restores the fonts to a predictable order. It should be invoked +// before any operation searching the fonts. +func (c *faceOrderer) resetFontOrder() { + copy(c.fonts, c.defaultOrderedFonts) +} + +func (c *faceOrderer) indexFor(face font.Face) int { + return c.faceToIndex[face] +} + +func (c *faceOrderer) faceFor(idx int) font.Face { + if idx < len(c.defaultOrderedFonts) { + return c.faces[c.defaultOrderedFonts[idx]] + } + panic("face index not found") +} + +// TODO(whereswaldon): this function could sort all faces by appropriateness for the +// given font characteristics. This would ensure that (if possible) text using a +// fallback font would select similar weights and emphases to the primary font. +func (c *faceOrderer) sortedFacesForStyle(font Font) []font.Face { + c.resetFontOrder() + primary, ok := c.fontForStyle(font) + if !ok { + font.Typeface = c.def.Typeface + primary, ok = c.fontForStyle(font) + if !ok { + primary = c.def + } + } + return c.sorted(primary) +} + +// fontForStyle returns the closest existing font to the requested font within the +// same typeface. +func (c *faceOrderer) fontForStyle(font Font) (Font, bool) { + if closest, ok := closestFont(font, c.fonts); ok { + return closest, true + } + font.Style = Regular + if closest, ok := closestFont(font, c.fonts); ok { + return closest, true + } + return font, false +} + +// faces returns a slice of faces with primary as the first element and +// the remaining faces ordered by insertion order. +func (f *faceOrderer) sorted(primary Font) []font.Face { + sort.Slice(f.fonts, func(i, j int) bool { + if f.fonts[i] == primary { + return true + } + a := f.fonts[i] + b := f.fonts[j] + return f.fontDefaultOrder[a] < f.fontDefaultOrder[b] + }) + for i, font := range f.fonts { + f.faceScratch[i] = f.faces[font] + } + return f.faceScratch +} + +// shaperImpl implements the shaping and line-wrapping of opentype fonts. +type shaperImpl struct { + // Fields for tracking fonts/faces. + orderer faceOrderer + + // Shaping and wrapping state. + shaper shaping.HarfbuzzShaper + wrapper shaping.LineWrapper + bidiParagraph bidi.Paragraph + + // Scratch buffers used to avoid re-allocating slices during routine internal + // shaping operations. + splitScratch1, splitScratch2 []shaping.Input + outScratchBuf []shaping.Output + scratchRunes []rune +} + +// Load registers the provided FontFace with the shaper, if it is compatible. +// It returns whether the face is now available for use. FontFaces are prioritized +// in the order in which they are loaded, with the first face being the default. +func (s *shaperImpl) Load(f FontFace) { + s.orderer.insert(f.Font, f.Face.Face()) +} + +// splitByScript divides the inputs into new, smaller inputs on script boundaries +// and correctly sets the text direction per-script. It will +// use buf as the backing memory for the returned slice if buf is non-nil. +func splitByScript(inputs []shaping.Input, documentDir di.Direction, buf []shaping.Input) []shaping.Input { + var splitInputs []shaping.Input + if buf == nil { + splitInputs = make([]shaping.Input, 0, len(inputs)) + } else { + splitInputs = buf + } + for _, input := range inputs { + currentInput := input + if input.RunStart == input.RunEnd { + return []shaping.Input{input} + } + firstNonCommonRune := input.RunStart + for i := firstNonCommonRune; i < input.RunEnd; i++ { + if language.LookupScript(input.Text[i]) != language.Common { + firstNonCommonRune = i + break + } + } + currentInput.Script = language.LookupScript(input.Text[firstNonCommonRune]) + for i := firstNonCommonRune + 1; i < input.RunEnd; i++ { + r := input.Text[i] + runeScript := language.LookupScript(r) + + if runeScript == language.Common || runeScript == currentInput.Script { + continue + } + + if i != input.RunStart { + currentInput.RunEnd = i + splitInputs = append(splitInputs, currentInput) + } + + currentInput = input + currentInput.RunStart = i + currentInput.Script = runeScript + // In the future, it may make sense to try to guess the language of the text here as well, + // but this is a complex process. + } + // close and add the last input + currentInput.RunEnd = input.RunEnd + splitInputs = append(splitInputs, currentInput) + } + + return splitInputs +} + +func (s *shaperImpl) splitBidi(input shaping.Input) []shaping.Input { + var splitInputs []shaping.Input + if input.Direction.Axis() != di.Horizontal || input.RunStart == input.RunEnd { + return []shaping.Input{input} + } + def := bidi.LeftToRight + if input.Direction.Progression() == di.TowardTopLeft { + def = bidi.RightToLeft + } + s.bidiParagraph.SetString(string(input.Text), bidi.DefaultDirection(def)) + out, err := s.bidiParagraph.Order() + if err != nil { + return []shaping.Input{input} + } + for i := 0; i < out.NumRuns(); i++ { + currentInput := input + run := out.Run(i) + dir := run.Direction() + _, endRune := run.Pos() + currentInput.RunEnd = endRune + 1 + if dir == bidi.RightToLeft { + currentInput.Direction = di.DirectionRTL + } else { + currentInput.Direction = di.DirectionLTR + } + splitInputs = append(splitInputs, currentInput) + input.RunStart = currentInput.RunEnd + } + return splitInputs +} + +// splitByFaces divides the inputs by font coverage in the provided faces. It will use the slice provided in buf +// as the backing storage of the returned slice if buf is non-nil. +func (s *shaperImpl) splitByFaces(inputs []shaping.Input, faces []font.Face, buf []shaping.Input) []shaping.Input { + var split []shaping.Input + if buf == nil { + split = make([]shaping.Input, 0, len(inputs)) + } else { + split = buf + } + for _, input := range inputs { + split = append(split, shaping.SplitByFontGlyphs(input, faces)...) + } + return split +} + +// shapeText invokes the text shaper and returns the raw text data in the shaper's native +// format. It does not wrap lines. +func (s *shaperImpl) shapeText(faces []font.Face, ppem fixed.Int26_6, lc system.Locale, txt []rune) []shaping.Output { + if len(faces) < 1 { + return nil + } + lcfg := langConfig{ + Language: language.NewLanguage(lc.Language), + Direction: mapDirection(lc.Direction), + } + // Create an initial input. + input := toInput(faces[0], ppem, lcfg, txt) + // Break input on font glyph coverage. + inputs := s.splitBidi(input) + inputs = s.splitByFaces(inputs, faces, s.splitScratch1[:0]) + inputs = splitByScript(inputs, lcfg.Direction, s.splitScratch2[:0]) + // Shape all inputs. + if needed := len(inputs) - len(s.outScratchBuf); needed > 0 { + s.outScratchBuf = slices.Grow(s.outScratchBuf, needed) + } + s.outScratchBuf = s.outScratchBuf[:len(inputs)] + for i := range inputs { + s.outScratchBuf[i] = s.shaper.Shape(inputs[i]) + } + return s.outScratchBuf +} + +// shapeAndWrapText invokes the text shaper and returns wrapped lines in the shaper's native format. +func (s *shaperImpl) shapeAndWrapText(faces []font.Face, ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt []rune) []shaping.Line { + // Wrap outputs into lines. + return s.wrapper.WrapParagraph(maxWidth, txt, s.shapeText(faces, ppem, lc, txt)...) +} + +// replaceControlCharacters replaces problematic unicode +// code points with spaces to ensure proper rune accounting. +func replaceControlCharacters(in []rune) []rune { + for i, r := range in { + switch r { + // ASCII File separator. + case '\u001C': + // ASCII Group separator. + case '\u001D': + // ASCII Record separator. + case '\u001E': + case '\r': + case '\n': + // Unicode "next line" character. + case '\u0085': + // Unicode "paragraph separator". + case '\u2029': + default: + continue + } + in[i] = ' ' + } + return in +} + +// Layout shapes and wraps the text, and returns the result in Gio's shaped text format. +func (s *shaperImpl) LayoutString(params Parameters, minWidth, maxWidth int, lc system.Locale, txt string) document { + return s.LayoutRunes(params, minWidth, maxWidth, lc, []rune(txt)) +} + +// Layout shapes and wraps the text, and returns the result in Gio's shaped text format. +func (s *shaperImpl) Layout(params Parameters, minWidth, maxWidth int, lc system.Locale, txt io.RuneReader) document { + s.scratchRunes = s.scratchRunes[:0] + for r, _, err := txt.ReadRune(); err != nil; r, _, err = txt.ReadRune() { + s.scratchRunes = append(s.scratchRunes, r) + } + return s.LayoutRunes(params, minWidth, maxWidth, lc, s.scratchRunes) +} + +func calculateYOffsets(lines []line) { + currentY := 0 + prevDesc := fixed.I(0) + for i := range lines { + ascent, descent := lines[i].ascent, lines[i].descent + currentY += (prevDesc + ascent).Ceil() + lines[i].yOffset = currentY + prevDesc = descent + } +} + +// LayoutRunes shapes and wraps the text, and returns the result in Gio's shaped text format. +func (s *shaperImpl) LayoutRunes(params Parameters, minWidth, maxWidth int, lc system.Locale, txt []rune) document { + hasNewline := len(txt) > 0 && txt[len(txt)-1] == '\n' + if hasNewline { + txt = txt[:len(txt)-1] + } + ls := s.shapeAndWrapText(s.orderer.sortedFacesForStyle(params.Font), params.PxPerEm, maxWidth, lc, replaceControlCharacters(txt)) + // Convert to Lines. + textLines := make([]line, len(ls)) + for i := range ls { + otLine := toLine(&s.orderer, ls[i], lc.Direction) + if i == len(ls)-1 && hasNewline { + // If there was a trailing newline update the rune counts to include + // it on the last line of the paragraph. + finalRunIdx := len(otLine.runs) - 1 + otLine.runeCount += 1 + otLine.runs[finalRunIdx].Runes.Count += 1 + + syntheticGlyph := glyph{ + id: 0, + clusterIndex: len(txt), + glyphCount: 0, + runeCount: 1, + xAdvance: 0, + yAdvance: 0, + xOffset: 0, + yOffset: 0, + } + // Inset the synthetic newline glyph on the proper end of the run. + if otLine.runs[finalRunIdx].Direction.Progression() == system.FromOrigin { + otLine.runs[finalRunIdx].Glyphs = append(otLine.runs[finalRunIdx].Glyphs, syntheticGlyph) + } else { + // Ensure capacity. + otLine.runs[finalRunIdx].Glyphs = append(otLine.runs[finalRunIdx].Glyphs, glyph{}) + copy(otLine.runs[finalRunIdx].Glyphs[1:], otLine.runs[finalRunIdx].Glyphs) + otLine.runs[finalRunIdx].Glyphs[0] = syntheticGlyph + } + } + textLines[i] = otLine + } + alignWidth := maxWidth + if len(textLines) == 1 { + alignWidth = max(minWidth, textLines[0].width.Ceil()) + } + calculateYOffsets(textLines) + return document{ + lines: textLines, + alignment: params.Alignment, + alignWidth: alignWidth, + } +} + +// Shape converts the provided glyphs into a path. +func (s *shaperImpl) Shape(ops *op.Ops, gs []Glyph) clip.PathSpec { + var lastPos f32.Point + var x fixed.Int26_6 + var builder clip.Path + builder.Begin(ops) + for i, g := range gs { + if i == 0 { + x = g.X + } + ppem, faceIdx, gid := splitGlyphID(g.ID) + face := s.orderer.faceFor(faceIdx) + ppemInt := ppem.Round() + ppem16 := uint16(ppemInt) + scaleFactor := float32(ppemInt) / float32(face.Upem()) + outline, ok := face.GlyphData(gid, ppem16, ppem16).(fonts.GlyphOutline) + if !ok { + continue + } + // Move to glyph position. + pos := f32.Point{ + X: float32(g.X-x)/64 - float32(g.Offset.X)/64, + Y: -float32(g.Offset.Y) / 64, + } + builder.Move(pos.Sub(lastPos)) + lastPos = pos + var lastArg f32.Point + + // Convert fonts.Segments to relative segments. + for _, fseg := range outline.Segments { + nargs := 1 + switch fseg.Op { + case fonts.SegmentOpQuadTo: + nargs = 2 + case fonts.SegmentOpCubeTo: + nargs = 3 + } + var args [3]f32.Point + for i := 0; i < nargs; i++ { + a := f32.Point{ + X: fseg.Args[i].X * scaleFactor, + Y: -fseg.Args[i].Y * scaleFactor, + } + args[i] = a.Sub(lastArg) + if i == nargs-1 { + lastArg = a + } + } + switch fseg.Op { + case fonts.SegmentOpMoveTo: + builder.Move(args[0]) + case fonts.SegmentOpLineTo: + builder.Line(args[0]) + case fonts.SegmentOpQuadTo: + builder.Quad(args[0], args[1]) + case fonts.SegmentOpCubeTo: + builder.Cube(args[0], args[1], args[2]) + default: + panic("unsupported segment op") + } + } + lastPos = lastPos.Add(lastArg) + } + return builder.End() +} + +// langConfig describes the language and writing system of a body of text. +type langConfig struct { + // Language the text is written in. + language.Language + // Writing system used to represent the text. + language.Script + // Direction of the text, usually driven by the writing system. + di.Direction +} + +// toInput converts its parameters into a shaping.Input. +func toInput(face font.Face, ppem fixed.Int26_6, lc langConfig, runes []rune) shaping.Input { + var input shaping.Input + input.Direction = lc.Direction + input.Text = runes + input.Size = ppem + input.Face = face + input.Language = lc.Language + input.Script = lc.Script + input.RunStart = 0 + input.RunEnd = len(runes) + return input +} + +func mapDirection(d system.TextDirection) di.Direction { + switch d { + case system.LTR: + return di.DirectionLTR + case system.RTL: + return di.DirectionRTL + } + return di.DirectionLTR +} + +func unmapDirection(d di.Direction) system.TextDirection { + switch d { + case di.DirectionLTR: + return system.LTR + case di.DirectionRTL: + return system.RTL + } + return system.LTR +} + +// toGioGlyphs converts text shaper glyphs into the minimal representation +// that Gio needs. +func toGioGlyphs(in []shaping.Glyph, ppem fixed.Int26_6, faceIdx int) []glyph { + out := make([]glyph, 0, len(in)) + for _, g := range in { + // To better understand how to calculate the bounding box, see here: + // https://freetype.org/freetype2/docs/glyphs/glyph-metrics-3.svg + var bounds fixed.Rectangle26_6 + bounds.Min.X = g.XBearing + bounds.Min.Y = -g.YBearing + bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height}) + out = append(out, glyph{ + id: newGlyphID(ppem, faceIdx, g.GlyphID), + clusterIndex: g.ClusterIndex, + runeCount: g.RuneCount, + glyphCount: g.GlyphCount, + xAdvance: g.XAdvance, + yAdvance: g.YAdvance, + xOffset: g.XOffset, + yOffset: g.YOffset, + bounds: bounds, + }) + } + return out +} + +// toLine converts the output into a Line with the provided dominant text direction. +func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line { + if len(o) < 1 { + return line{} + } + line := line{ + runs: make([]runLayout, len(o)), + direction: dir, + } + for i := range o { + run := o[i] + line.runs[i] = runLayout{ + Glyphs: toGioGlyphs(run.Glyphs, run.Size, orderer.indexFor(run.Face)), + Runes: Range{ + Count: run.Runes.Count, + Offset: line.runeCount, + }, + Direction: unmapDirection(run.Direction), + face: run.Face, + Advance: run.Advance, + PPEM: run.Size, + } + line.runeCount += run.Runes.Count + if line.bounds.Min.Y > -run.LineBounds.Ascent { + line.bounds.Min.Y = -run.LineBounds.Ascent + } + if line.bounds.Max.Y < -run.LineBounds.Ascent+run.LineBounds.LineHeight() { + line.bounds.Max.Y = -run.LineBounds.Ascent + run.LineBounds.LineHeight() + } + line.bounds.Max.X += run.Advance + line.width += run.Advance + if line.ascent < run.LineBounds.Ascent { + line.ascent = run.LineBounds.Ascent + } + if line.descent < -run.LineBounds.Descent+run.LineBounds.Gap { + line.descent = -run.LineBounds.Descent + run.LineBounds.Gap + } + } + computeVisualOrder(&line) + // Account for glyphs hanging off of either side in the bounds. + if len(line.visualOrder) > 0 { + runIdx := line.visualOrder[0] + run := o[runIdx] + if len(run.Glyphs) > 0 { + line.bounds.Min.X = run.Glyphs[0].LeftSideBearing() + } + runIdx = line.visualOrder[len(line.visualOrder)-1] + run = o[runIdx] + if len(run.Glyphs) > 0 { + lastGlyphIdx := len(run.Glyphs) - 1 + line.bounds.Max.X += run.Glyphs[lastGlyphIdx].RightSideBearing() + } + } + return line +} + +// computeVisualOrder will populate the Line's VisualOrder field and the +// VisualPosition field of each element in Runs. +func computeVisualOrder(l *line) { + l.visualOrder = make([]int, len(l.runs)) + const none = -1 + bidiRangeStart := none + + // visPos returns the visual position for an individual logically-indexed + // run in this line, taking only the line's overall text direction into + // account. + visPos := func(logicalIndex int) int { + if l.direction.Progression() == system.TowardOrigin { + return len(l.runs) - 1 - logicalIndex + } + return logicalIndex + } + + // resolveBidi populated the line's VisualOrder fields for the elements in the + // half-open range [bidiRangeStart:bidiRangeEnd) indicating that those elements + // should be displayed in reverse-visual order. + resolveBidi := func(bidiRangeStart, bidiRangeEnd int) { + firstVisual := bidiRangeEnd - 1 + // Just found the end of a bidi range. + for startIdx := bidiRangeStart; startIdx < bidiRangeEnd; startIdx++ { + pos := visPos(firstVisual) + l.runs[startIdx].VisualPosition = pos + l.visualOrder[pos] = startIdx + firstVisual-- + } + bidiRangeStart = none + } + for runIdx, run := range l.runs { + if run.Direction.Progression() != l.direction.Progression() { + if bidiRangeStart == none { + bidiRangeStart = runIdx + } + continue + } else if bidiRangeStart != none { + // Just found the end of a bidi range. + resolveBidi(bidiRangeStart, runIdx) + bidiRangeStart = none + } + pos := visPos(runIdx) + l.runs[runIdx].VisualPosition = pos + l.visualOrder[pos] = runIdx + } + if bidiRangeStart != none { + // We ended iteration within a bidi segment, resolve it. + resolveBidi(bidiRangeStart, len(l.runs)) + } + // Iterate and resolve the X of each run. + x := fixed.Int26_6(0) + for _, runIdx := range l.visualOrder { + l.runs[runIdx].X = x + x += l.runs[runIdx].Advance + } +} + +// closestFont returns the closest Font in available by weight. +// In case of equality the lighter weight will be returned. +func closestFont(lookup Font, available []Font) (Font, bool) { + found := false + var match Font + for _, cf := range available { + if cf == lookup { + return lookup, true + } + if cf.Typeface != lookup.Typeface || cf.Variant != lookup.Variant || cf.Style != lookup.Style { + continue + } + if !found { + found = true + match = cf + continue + } + cDist := weightDistance(lookup.Weight, cf.Weight) + mDist := weightDistance(lookup.Weight, match.Weight) + if cDist < mDist { + match = cf + } else if cDist == mDist && cf.Weight < match.Weight { + match = cf + } + } + return match, found +} + +// weightDistance returns the distance value between two font weights. +func weightDistance(wa Weight, wb Weight) int { + // Avoid dealing with negative Weight values. + a := int(wa) + 400 + b := int(wb) + 400 + diff := a - b + if diff < 0 { + return -diff + } + return diff +} diff --git a/text/gotext_test.go b/text/gotext_test.go new file mode 100644 index 00000000..969743e7 --- /dev/null +++ b/text/gotext_test.go @@ -0,0 +1,650 @@ +package text + +import ( + "math" + "reflect" + "testing" + + nsareg "eliasnaur.com/font/noto/sans/arabic/regular" + "github.com/go-text/typesetting/shaping" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/math/fixed" + + "gioui.org/font/opentype" + "gioui.org/io/system" +) + +var english = system.Locale{ + Language: "EN", + Direction: system.LTR, +} + +var arabic = system.Locale{ + Language: "AR", + Direction: system.RTL, +} + +func testShaper(faces ...Face) *shaperImpl { + shaper := shaperImpl{} + for _, face := range faces { + shaper.Load(FontFace{Face: face}) + } + return &shaper +} + +func TestEmptyString(t *testing.T) { + ppem := fixed.I(200) + ltrFace, _ := opentype.Parse(goregular.TTF) + shaper := testShaper(ltrFace) + + lines := shaper.LayoutRunes(Parameters{PxPerEm: ppem}, 0, 2000, english, []rune{}) + if len(lines.lines) == 0 { + t.Fatalf("Layout returned no lines for empty string; expected 1") + } + l := lines.lines[0] + exp := fixed.Rectangle26_6{ + Min: fixed.Point26_6{ + Y: fixed.Int26_6(-12094), + }, + Max: fixed.Point26_6{ + Y: fixed.Int26_6(2700), + }, + } + if got := l.bounds; got != exp { + t.Errorf("got bounds %+v for empty string; expected %+v", got, exp) + } +} + +// TestNewlineSynthesis ensures that the shaper correctly inserts synthetic glyphs +// representing newline runes. +func TestNewlineSynthesis(t *testing.T) { + ppem := fixed.I(10) + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + shaper := testShaper(ltrFace, rtlFace) + + type testcase struct { + name string + locale system.Locale + txt string + } + for _, tc := range []testcase{ + { + name: "ltr bidi newline in rtl segment", + locale: english, + txt: "The quick سماء שלום لا fox تمط שלום\n", + }, + { + name: "ltr bidi newline in ltr segment", + locale: english, + txt: "The quick سماء שלום لا fox\n", + }, + { + name: "rtl bidi newline in ltr segment", + locale: arabic, + txt: "الحب سماء brown привет fox تمط jumps\n", + }, + { + name: "rtl bidi newline in rtl segment", + locale: arabic, + txt: "الحب سماء brown привет fox تمط\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + + doc := shaper.LayoutRunes(Parameters{PxPerEm: ppem}, 0, 200, tc.locale, []rune(tc.txt)) + for lineIdx, line := range doc.lines { + lastRunIdx := len(line.runs) - 1 + lastRun := line.runs[lastRunIdx] + lastGlyphIdx := len(lastRun.Glyphs) - 1 + if lastRun.Direction.Progression() == system.TowardOrigin { + lastGlyphIdx = 0 + } + glyph := lastRun.Glyphs[lastGlyphIdx] + if glyph.glyphCount != 0 { + t.Errorf("expected synthetic newline on line %d, run %d, glyph %d", lineIdx, lastRunIdx, lastGlyphIdx) + } + for runIdx, run := range line.runs { + for glyphIdx, glyph := range run.Glyphs { + if runIdx == lastRunIdx && glyphIdx == lastGlyphIdx { + continue + } + if glyph.glyphCount == 0 { + t.Errorf("found invalid synthetic newline on line %d, run %d, glyph %d", lineIdx, runIdx, glyphIdx) + } + } + } + } + if t.Failed() { + printLinePositioning(t, doc.lines, nil) + } + }) + } + +} + +// simpleGlyph returns a simple square glyph with the provided cluster +// value. +func simpleGlyph(cluster int) shaping.Glyph { + return complexGlyph(cluster, 1, 1) +} + +// ligatureGlyph returns a simple square glyph with the provided cluster +// value and number of runes. +func ligatureGlyph(cluster, runes int) shaping.Glyph { + return complexGlyph(cluster, runes, 1) +} + +// expansionGlyph returns a simple square glyph with the provided cluster +// value and number of glyphs. +func expansionGlyph(cluster, glyphs int) shaping.Glyph { + return complexGlyph(cluster, 1, glyphs) +} + +// complexGlyph returns a simple square glyph with the provided cluster +// value, number of associated runes, and number of glyphs in the cluster. +func complexGlyph(cluster, runes, glyphs int) shaping.Glyph { + return shaping.Glyph{ + Width: fixed.I(10), + Height: fixed.I(10), + XAdvance: fixed.I(10), + YAdvance: fixed.I(10), + YBearing: fixed.I(10), + ClusterIndex: cluster, + GlyphCount: glyphs, + RuneCount: runes, + } +} + +// makeTestText creates a simple and complex(bidi) sample of shaped text at the given +// font size and wrapped to the given line width. The runeLimit, if nonzero, +// truncates the sample text to ensure shorter output for expensive tests. +func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize, lineWidth, runeLimit int) (simpleSample, complexSample []shaping.Line) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + if shaper == nil { + shaper = testShaper(ltrFace, rtlFace) + } + + ltrSource := "The quick brown fox jumps over the lazy dog." + rtlSource := "الحب سماء لا تمط غير الأحلام" + // bidiSource is crafted to contain multiple consecutive RTL runs (by + // changing scripts within the RTL). + bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog." + // bidi2Source is crafted to contain multiple consecutive LTR runs (by + // changing scripts within the LTR). + bidi2Source := "الحب سماء brown привет fox تمط jumps привет over غير الأحلام" + + locale := english + simpleSource := ltrSource + complexSource := bidiSource + if primaryDir == system.RTL { + simpleSource = rtlSource + complexSource = bidi2Source + locale = arabic + } + if runeLimit != 0 { + simpleRunes := []rune(simpleSource) + complexRunes := []rune(complexSource) + if runeLimit < len(simpleRunes) { + ltrSource = string(simpleRunes[:runeLimit]) + } + if runeLimit < len(complexRunes) { + rtlSource = string(complexRunes[:runeLimit]) + } + } + simpleText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), fixed.I(fontSize), lineWidth, locale, []rune(simpleSource)) + complexText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), fixed.I(fontSize), lineWidth, locale, []rune(complexSource)) + shaper = testShaper(rtlFace, ltrFace) + return simpleText, complexText +} + +func fixedAbs(a fixed.Int26_6) fixed.Int26_6 { + if a < 0 { + a = -a + } + return a +} + +func TestToLine(t *testing.T) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + shaper := testShaper(ltrFace, rtlFace) + ltr, bidi := makeTestText(shaper, system.LTR, 16, 100, 0) + rtl, bidi2 := makeTestText(shaper, system.RTL, 16, 100, 0) + _, bidiWide := makeTestText(shaper, system.LTR, 16, 200, 0) + _, bidi2Wide := makeTestText(shaper, system.RTL, 16, 200, 0) + type testcase struct { + name string + lines []shaping.Line + // Dominant text direction. + dir system.TextDirection + } + for _, tc := range []testcase{ + { + name: "ltr", + lines: ltr, + dir: system.LTR, + }, + { + name: "rtl", + lines: rtl, + dir: system.RTL, + }, + { + name: "bidi", + lines: bidi, + dir: system.LTR, + }, + { + name: "bidi2", + lines: bidi2, + dir: system.RTL, + }, + { + name: "bidi_wide", + lines: bidiWide, + dir: system.LTR, + }, + { + name: "bidi2_wide", + lines: bidi2Wide, + dir: system.RTL, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // We expect: + // - Line dimensions to be populated. + // - Line direction to be populated. + // - Runs to be ordered from lowest runes first. + // - Runs to have widths matching the input. + // - Runs to have the same total number of glyphs/runes as the input. + runesSeen := Range{} + shaper := testShaper(ltrFace, rtlFace) + for i, input := range tc.lines { + seenRun := make([]bool, len(input)) + inputLowestRuneOffset := math.MaxInt + totalInputGlyphs := 0 + totalInputRunes := 0 + for _, run := range input { + if run.Runes.Offset < inputLowestRuneOffset { + inputLowestRuneOffset = run.Runes.Offset + } + totalInputGlyphs += len(run.Glyphs) + totalInputRunes += run.Runes.Count + } + output := toLine(&shaper.orderer, input, tc.dir) + if output.bounds.Min == (fixed.Point26_6{}) { + t.Errorf("line %d: Bounds.Min not populated", i) + } + if output.bounds.Max == (fixed.Point26_6{}) { + t.Errorf("line %d: Bounds.Max not populated", i) + } + if output.direction != tc.dir { + t.Errorf("line %d: expected direction %v, got %v", i, tc.dir, output.direction) + } + totalRunWidth := fixed.I(0) + totalLineGlyphs := 0 + totalLineRunes := 0 + for k, run := range output.runs { + seenRun[run.VisualPosition] = true + if output.visualOrder[run.VisualPosition] != k { + t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, output.visualOrder[run.VisualPosition], k) + } + if run.Runes.Offset != totalLineRunes { + t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, totalLineRunes, run.Runes.Offset) + } + runGlyphCount := len(run.Glyphs) + if inputGlyphs := len(input[k].Glyphs); runGlyphCount != inputGlyphs { + t.Errorf("line %d, run %d: expected %d glyphs, found %d", i, k, inputGlyphs, runGlyphCount) + } + runRuneCount := 0 + currentCluster := -1 + for _, g := range run.Glyphs { + if g.clusterIndex != currentCluster { + runRuneCount += g.runeCount + currentCluster = g.clusterIndex + } + } + if run.Runes.Count != runRuneCount { + t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount) + } + runesSeen.Count += run.Runes.Count + totalRunWidth += fixedAbs(run.Advance) + totalLineGlyphs += len(run.Glyphs) + totalLineRunes += run.Runes.Count + } + if output.runeCount != totalInputRunes { + t.Errorf("line %d: input had %d runes, only counted %d", i, totalInputRunes, output.runeCount) + } + if totalLineGlyphs != totalInputGlyphs { + t.Errorf("line %d: input had %d glyphs, only counted %d", i, totalInputRunes, totalLineGlyphs) + } + if totalRunWidth != output.width { + t.Errorf("line %d: expected width %d, got %d", i, totalRunWidth, output.width) + } + for runIndex, seen := range seenRun { + if !seen { + t.Errorf("line %d, run %d missing from runs VisualPosition fields", i, runIndex) + } + } + } + lastLine := tc.lines[len(tc.lines)-1] + maxRunes := 0 + for _, run := range lastLine { + if run.Runes.Count+run.Runes.Offset > maxRunes { + maxRunes = run.Runes.Count + run.Runes.Offset + } + } + if runesSeen.Count != maxRunes { + t.Errorf("input covered %d runes, output only covers %d", maxRunes, runesSeen.Count) + } + }) + } +} + +func TestComputeVisualOrder(t *testing.T) { + type testcase struct { + name string + input line + expectedVisualOrder []int + } + for _, tc := range []testcase{ + { + name: "ltr", + input: line{ + direction: system.LTR, + runs: []runLayout{ + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.LTR}, + }, + }, + expectedVisualOrder: []int{0, 1, 2}, + }, + { + name: "rtl", + input: line{ + direction: system.RTL, + runs: []runLayout{ + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.RTL}, + }, + }, + expectedVisualOrder: []int{2, 1, 0}, + }, + { + name: "bidi-ltr", + input: line{ + direction: system.LTR, + runs: []runLayout{ + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.LTR}, + }, + }, + expectedVisualOrder: []int{0, 3, 2, 1, 4}, + }, + { + name: "bidi-ltr-complex", + input: line{ + direction: system.LTR, + runs: []runLayout{ + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.RTL}, + }, + }, + expectedVisualOrder: []int{1, 0, 2, 4, 3, 5, 7, 6, 8, 10, 9}, + }, + { + name: "bidi-rtl", + input: line{ + direction: system.RTL, + runs: []runLayout{ + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.RTL}, + }, + }, + expectedVisualOrder: []int{4, 1, 2, 3, 0}, + }, + { + name: "bidi-rtl-complex", + input: line{ + direction: system.RTL, + runs: []runLayout{ + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.LTR}, + {Direction: system.RTL}, + {Direction: system.LTR}, + {Direction: system.LTR}, + }, + }, + expectedVisualOrder: []int{9, 10, 8, 6, 7, 5, 3, 4, 2, 0, 1}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + computeVisualOrder(&tc.input) + if !reflect.DeepEqual(tc.input.visualOrder, tc.expectedVisualOrder) { + t.Errorf("expected visual order %v, got %v", tc.expectedVisualOrder, tc.input.visualOrder) + } + for i, visualIndex := range tc.input.visualOrder { + if pos := tc.input.runs[visualIndex].VisualPosition; pos != i { + t.Errorf("line.VisualOrder[%d]=%d, but line.Runs[%d].VisualPosition=%d", i, visualIndex, visualIndex, pos) + } + } + }) + } +} + +func FuzzLayout(f *testing.F) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + f.Add("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.", true, uint8(10), uint16(200)) + + shaper := testShaper(ltrFace, rtlFace) + f.Fuzz(func(t *testing.T, txt string, rtl bool, fontSize uint8, width uint16) { + locale := system.Locale{ + Direction: system.LTR, + } + if rtl { + locale.Direction = system.RTL + } + if fontSize < 1 { + fontSize = 1 + } + lines := shaper.LayoutRunes(Parameters{PxPerEm: fixed.I(int(fontSize))}, 0, int(width), locale, []rune(txt)) + validateLines(t, lines.lines, len([]rune(txt))) + }) +} + +func validateLines(t *testing.T, lines []line, expectedRuneCount int) { + t.Helper() + runesSeen := 0 + for i, line := range lines { + if line.bounds.Min == (fixed.Point26_6{}) { + t.Errorf("line %d: Bounds.Min not populated", i) + } + if line.bounds.Max == (fixed.Point26_6{}) { + t.Errorf("line %d: Bounds.Max not populated", i) + } + totalRunWidth := fixed.I(0) + totalLineGlyphs := 0 + lineRunesSeen := 0 + for k, run := range line.runs { + if line.visualOrder[run.VisualPosition] != k { + t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, line.visualOrder[run.VisualPosition], k) + } + if run.Runes.Offset != lineRunesSeen { + t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, lineRunesSeen, run.Runes.Offset) + } + runRuneCount := 0 + currentCluster := -1 + for _, g := range run.Glyphs { + if g.clusterIndex != currentCluster { + runRuneCount += g.runeCount + currentCluster = g.clusterIndex + } + } + if run.Runes.Count != runRuneCount { + t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount) + } + lineRunesSeen += run.Runes.Count + totalRunWidth += fixedAbs(run.Advance) + totalLineGlyphs += len(run.Glyphs) + } + if totalRunWidth != line.width { + t.Errorf("line %d: expected width %d, got %d", i, line.width, totalRunWidth) + } + runesSeen += lineRunesSeen + } + if runesSeen != expectedRuneCount { + t.Errorf("input covered %d runes, output only covers %d", expectedRuneCount, runesSeen) + } +} + +// TestTextAppend ensures that appending two texts together correctly updates the new lines' +// y offsets. +func TestTextAppend(t *testing.T) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + + shaper := testShaper(ltrFace, rtlFace) + + text1 := shaper.LayoutString(Parameters{ + PxPerEm: fixed.I(14), + }, 0, 200, english, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.") + text2 := shaper.LayoutString(Parameters{ + PxPerEm: fixed.I(14), + }, 0, 200, english, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.") + + text1.append(text2) + curY := math.MinInt + for lineNum, line := range text1.lines { + yOff := line.yOffset + if yOff <= curY { + t.Errorf("lines[%d] has y offset %d, <= to previous %d", lineNum, yOff, curY) + } + curY = yOff + } +} + +func TestClosestFontByWeight(t *testing.T) { + const ( + testTF1 Typeface = "MockFace" + testTF2 Typeface = "TestFace" + testTF3 Typeface = "AnotherFace" + ) + fonts := []Font{ + {Typeface: testTF1, Style: Regular, Weight: Normal}, + {Typeface: testTF1, Style: Regular, Weight: Light}, + {Typeface: testTF1, Style: Regular, Weight: Bold}, + {Typeface: testTF1, 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}, + } + for _, test := range weightOnlyTests { + got, ok := closestFont(Font{Typeface: testTF1, Weight: test.Lookup}, fonts) + if !ok { + t.Errorf("expected closest font for %v to exist", test.Lookup) + } + if got.Weight != test.Expected { + t.Errorf("got weight %v, expected %v", got.Weight, test.Expected) + } + } + fonts = []Font{ + {Typeface: testTF1, Style: Regular, Weight: Light}, + {Typeface: testTF1, Style: Regular, Weight: Bold}, + {Typeface: testTF1, Style: Italic, Weight: Normal}, + {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, + }, + } + for _, test := range otherTests { + got, ok := closestFont(test.Lookup, fonts) + if test.ExpectedToFail { + if ok { + t.Errorf("expected closest font for %v to not exist", test.Lookup) + } else { + continue + } + } + if !ok { + t.Errorf("expected closest font for %v to exist", test.Lookup) + } + if got != test.Expected { + t.Errorf("got %v, expected %v", got, test.Expected) + } + } +} diff --git a/text/lru.go b/text/lru.go index 02996889..ef8e065e 100644 --- a/text/lru.go +++ b/text/lru.go @@ -3,9 +3,11 @@ package text import ( + "encoding/binary" + "hash/maphash" + "gioui.org/io/system" "gioui.org/op/clip" - "github.com/benoitkugler/textlayout/fonts" "golang.org/x/image/math/fixed" ) @@ -15,47 +17,54 @@ type layoutCache struct { } type pathCache struct { - m map[pathKey]*path + seed maphash.Seed + m map[uint64]*path head, tail *path } type layoutElem struct { next, prev *layoutElem key layoutKey - layout []Line + layout document } type path struct { next, prev *path - key pathKey + key uint64 val clip.PathSpec - gids []fonts.GID + glyphs []glyphInfo +} + +type glyphInfo struct { + ID GlyphID + X fixed.Int26_6 } type layoutKey struct { - ppem fixed.Int26_6 - maxWidth int - str string - locale system.Locale + ppem fixed.Int26_6 + maxWidth, minWidth int + maxLines int + str string + locale system.Locale + font Font } type pathKey struct { - ppem fixed.Int26_6 gidHash uint64 } const maxSize = 1000 -func (l *layoutCache) Get(k layoutKey) ([]Line, bool) { +func (l *layoutCache) Get(k layoutKey) (document, bool) { if lt, ok := l.m[k]; ok { l.remove(lt) l.insert(lt) return lt.layout, true } - return nil, false + return document{}, false } -func (l *layoutCache) Put(k layoutKey, lt []Line) { +func (l *layoutCache) Put(k layoutKey, lt document) { if l.m == nil { l.m = make(map[layoutKey]*layoutElem) l.head = new(layoutElem) @@ -85,20 +94,49 @@ func (l *layoutCache) insert(lt *layoutElem) { lt.next.prev = lt } -func gidsMatch(gids []fonts.GID, l Layout) bool { - if len(gids) != len(l.Glyphs) { +// hashGlyphs computes a hash key based on the ID and X offset of +// every glyph in the slice. +func (c *pathCache) hashGlyphs(gs []Glyph) uint64 { + if c.seed == (maphash.Seed{}) { + c.seed = maphash.MakeSeed() + } + var h maphash.Hash + h.SetSeed(c.seed) + var b [8]byte + firstX := fixed.Int26_6(0) + for i, g := range gs { + if i == 0 { + firstX = g.X + } + // Cache glyph X offsets relative to the first glyph. + binary.LittleEndian.PutUint32(b[:4], uint32(g.X-firstX)) + h.Write(b[:4]) + binary.LittleEndian.PutUint64(b[:], uint64(g.ID)) + h.Write(b[:]) + } + sum := h.Sum64() + return sum +} + +func gidsEqual(a []glyphInfo, glyphs []Glyph) bool { + if len(a) != len(glyphs) { return false } - for i := range gids { - if gids[i] != l.Glyphs[i].ID { + firstX := fixed.Int26_6(0) + for i := range a { + if i == 0 { + firstX = glyphs[i].X + } + // Cache glyph X offsets relative to the first glyph. + if a[i].ID != glyphs[i].ID || a[i].X != (glyphs[i].X-firstX) { return false } } return true } -func (c *pathCache) Get(k pathKey, l Layout) (clip.PathSpec, bool) { - if v, ok := c.m[k]; ok && gidsMatch(v.gids, l) { +func (c *pathCache) Get(key uint64, gs []Glyph) (clip.PathSpec, bool) { + if v, ok := c.m[key]; ok && gidsEqual(v.glyphs, gs) { c.remove(v) c.insert(v) return v.val, true @@ -106,20 +144,25 @@ func (c *pathCache) Get(k pathKey, l Layout) (clip.PathSpec, bool) { return clip.PathSpec{}, false } -func (c *pathCache) Put(k pathKey, l Layout, v clip.PathSpec) { +func (c *pathCache) Put(key uint64, glyphs []Glyph, v clip.PathSpec) { if c.m == nil { - c.m = make(map[pathKey]*path) + c.m = make(map[uint64]*path) c.head = new(path) c.tail = new(path) c.head.prev = c.tail c.tail.next = c.head } - gids := make([]fonts.GID, len(l.Glyphs)) - for i := range l.Glyphs { - gids[i] = l.Glyphs[i].ID + gids := make([]glyphInfo, len(glyphs)) + firstX := fixed.I(0) + for i, glyph := range glyphs { + if i == 0 { + firstX = glyph.X + } + // Cache glyph X offsets relative to the first glyph. + gids[i] = glyphInfo{ID: glyph.ID, X: glyph.X - firstX} } - val := &path{key: k, val: v, gids: gids} - c.m[k] = val + val := &path{key: key, val: v, glyphs: gids} + c.m[key] = val c.insert(val) if len(c.m) > maxSize { oldest := c.tail.next diff --git a/text/lru_test.go b/text/lru_test.go index 0c5cb528..a7af3259 100644 --- a/text/lru_test.go +++ b/text/lru_test.go @@ -12,7 +12,7 @@ import ( func TestLayoutLRU(t *testing.T) { c := new(layoutCache) put := func(i int) { - c.Put(layoutKey{str: strconv.Itoa(i)}, nil) + c.Put(layoutKey{str: strconv.Itoa(i)}, document{}) } get := func(i int) bool { _, ok := c.Get(layoutKey{str: strconv.Itoa(i)}) @@ -23,11 +23,12 @@ func TestLayoutLRU(t *testing.T) { func TestPathLRU(t *testing.T) { c := new(pathCache) + shaped := []Glyph{{ID: 1}} put := func(i int) { - c.Put(pathKey{gidHash: uint64(i)}, Layout{Runes: Range{Count: i}}, clip.PathSpec{}) + c.Put(uint64(i), shaped, clip.PathSpec{}) } get := func(i int) bool { - _, ok := c.Get(pathKey{gidHash: uint64(i)}, Layout{Runes: Range{Count: i}}) + _, ok := c.Get(uint64(i), shaped) return ok } testLRU(t, put, get) diff --git a/text/shaper.go b/text/shaper.go index 942c8097..c13d3aa0 100644 --- a/text/shaper.go +++ b/text/shaper.go @@ -3,25 +3,29 @@ package text import ( - "encoding/binary" - "hash/maphash" + "fmt" "io" "strings" - - "golang.org/x/image/math/fixed" + "unicode/utf8" "gioui.org/io/system" + "gioui.org/op" "gioui.org/op/clip" + "github.com/go-text/typesetting/font" + "golang.org/x/image/math/fixed" ) -// Shaper implements layout and shaping of text. -type Shaper interface { - // Layout a text according to a set of options. - Layout(font Font, size fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) ([]Line, error) - // LayoutString is Layout for strings. - LayoutString(font Font, size fixed.Int26_6, maxWidth int, lc system.Locale, str string) []Line - // Shape a line of text and return a clipping operation for its outline. - Shape(font Font, size fixed.Int26_6, layout Layout) clip.PathSpec +// Parameters are static text shaping attributes applied to the entire shaped text. +type Parameters struct { + // Font describes the preferred typeface. + Font Font + // Alignment characterizes the positioning of text within the line. It does not directly + // impact shaping, but is provided in order to allow efficient offset computation. + Alignment Alignment + // PxPerEm is the pixels-per-em to shape the text with. + PxPerEm fixed.Int26_6 + // MaxLines limits the quantity of shaped lines. Zero means no limit. + MaxLines int } // A FontFace is a Font and a matching Face. @@ -30,152 +34,378 @@ type FontFace struct { Face Face } -// Cache implements cached layout and shaping of text from a set of -// registered fonts. +// Glyph describes a shaped font glyph. Many fields are distances relative +// to the "dot", which is a point on the baseline (the line upon which glyphs +// visually rest) for the line of text containing the glyph. // -// If a font matches no registered shape, Cache falls back to the -// first registered face. +// Glyphs are organized into "glyph clusters," which are sequences that +// may represent an arbitrary number of runes. // -// The LayoutString and ShapeString results are cached and re-used if -// possible. -type Cache struct { - def Typeface - faces map[Font]*faceCache +// Sequences of glyph clusters that share style parameters are grouped into "runs." +// +// "Document coordinates" are pixel values relative to the text's origin at (0,0) +// in the upper-left corner" Displaying each shaped glyph at the document +// coordinates of its dot will correctly visualize the text. +type Glyph struct { + // ID is a unique, per-shaper identifier for the shape of the glyph. + // Glyphs from the same shaper will share an ID when they are from + // the same face and represent the same glyph at the same size. + ID GlyphID + + // X is the x coordinate of the dot for this glyph in document coordinates. + X fixed.Int26_6 + // Y is the y coordinate of the dot for this glyph in document coordinates. + Y int32 + + // Advance is the logical width of the glyph. The glyph may be visually + // wider than this. + Advance fixed.Int26_6 + // Ascent is the distance from the dot to the logical top of glyphs in + // this glyph's face. The specific glyph may be shorter than this. + Ascent fixed.Int26_6 + // Descent is the distance from the dot to the logical bottom of glyphs + // in this glyph's face. The specific glyph may descend less than this. + Descent fixed.Int26_6 + // Offset encodes the origin of the drawing coordinate space for this glyph + // relative to the dot. This value is used when converting glyphs to paths. + Offset fixed.Point26_6 + // Bounds encodes the visual dimensions of the glyph relative to the dot. + Bounds fixed.Rectangle26_6 + // Runes is the number of runes represented by the glyph cluster this glyph + // belongs to. If Flags does not contain FlagClusterBreak, this value will + // always be zero. The final glyph in the cluster contains the runes count + // for the entire cluster. + Runes byte + // Flags encode special properties of this glyph. + Flags Flags } -type faceCache struct { - face Face - layoutCache layoutCache +type Flags uint16 + +const ( + // FlagTowardOrigin is set for glyphs in runs that flow + // towards the origin (RTL). + FlagTowardOrigin Flags = 1 << iota + // FlagLineBreak is set for the last glyph in a line. + FlagLineBreak + // FlagRunBreak is set for the last glyph in a run. A run is a sequence of + // glyphs sharing constant style properties (same size, same face, same + // direction, etc...). + FlagRunBreak + // FlagClusterBreak is set for the last glyph in a glyph cluster. A glyph cluster is a + // 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 + // font glyphs, but was inserted by the shaper to represent line-breaking + // whitespace characters. + FlagSynthetic +) + +func (f Flags) String() string { + var b strings.Builder + if f&FlagSynthetic > 0 { + b.WriteString("S") + } else { + b.WriteString("_") + } + if f&FlagTowardOrigin > 0 { + b.WriteString("T") + } else { + b.WriteString("_") + } + if f&FlagLineBreak > 0 { + b.WriteString("L") + } else { + b.WriteString("_") + } + if f&FlagRunBreak > 0 { + b.WriteString("R") + } else { + b.WriteString("_") + } + if f&FlagClusterBreak > 0 { + b.WriteString("C") + } else { + b.WriteString("_") + } + return b.String() +} + +type GlyphID uint64 + +// Shaper converts strings of text into glyphs that can be displayed. +type Shaper struct { + shaper shaperImpl pathCache pathCache - seed maphash.Seed + layoutCache layoutCache + paragraph []rune + + reader strings.Reader + + // Iterator state. + 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. + done bool + err error } -func (c *Cache) lookup(font Font) *faceCache { - f := c.faceForStyle(font) - if f == nil { - font.Typeface = c.def - f = c.faceForStyle(font) +// NewShaper constructs a shaper with the provided collection of font faces +// available. +func NewShaper(collection []FontFace) *Shaper { + l := &Shaper{} + for _, f := range collection { + l.shaper.Load(f) } - return f -} - -func (c *Cache) faceForStyle(font Font) *faceCache { - if closest, ok := c.closestFont(font); ok { - return c.faces[closest] - } - font.Style = Regular - if closest, ok := c.closestFont(font); ok { - return c.faces[closest] - } - return nil -} - -// closestFont returns the closest Font by weight, in case of equality the -// lighter weight will be returned. -func (c *Cache) closestFont(lookup Font) (Font, bool) { - if c.faces[lookup] != nil { - return lookup, true - } - found := false - var match Font - for cf := range c.faces { - if cf.Typeface != lookup.Typeface || cf.Variant != lookup.Variant || cf.Style != lookup.Style { - continue - } - if !found { - found = true - match = cf - continue - } - cDist := weightDistance(lookup.Weight, cf.Weight) - mDist := weightDistance(lookup.Weight, match.Weight) - if cDist < mDist { - match = cf - } else if cDist == mDist && cf.Weight < match.Weight { - match = cf - } - } - return match, found -} - -func NewCache(collection []FontFace) *Cache { - c := &Cache{ - faces: make(map[Font]*faceCache), - } - for i, ff := range collection { - if i == 0 { - c.def = ff.Font.Typeface - } - c.faces[ff.Font] = &faceCache{face: ff.Face} - } - return c -} - -// Layout implements the Shaper interface. -func (c *Cache) Layout(font Font, size fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) ([]Line, error) { - cache := c.lookup(font) - return cache.face.Layout(size, maxWidth, lc, txt) -} - -// LayoutString is a caching implementation of the Shaper interface. -func (c *Cache) LayoutString(font Font, size fixed.Int26_6, maxWidth int, lc system.Locale, str string) []Line { - cache := c.lookup(font) - return cache.layout(size, maxWidth, lc, str) -} - -// Shape is a caching implementation of the Shaper interface. Shape assumes that the layout -// argument is unchanged from a call to Layout or LayoutString. -func (c *Cache) Shape(font Font, size fixed.Int26_6, layout Layout) clip.PathSpec { - cache := c.lookup(font) - return cache.shape(size, layout) -} - -func (f *faceCache) layout(ppem fixed.Int26_6, maxWidth int, lc system.Locale, str string) []Line { - if f == nil { - return nil - } - lk := layoutKey{ - ppem: ppem, - maxWidth: maxWidth, - str: str, - locale: lc, - } - if l, ok := f.layoutCache.Get(lk); ok { - return l - } - l, _ := f.face.Layout(ppem, maxWidth, lc, strings.NewReader(str)) - f.layoutCache.Put(lk, l) return l } -// hashGIDs returns a 64-bit hash value of the font GIDs contained -// within the provided layout. -func (f *faceCache) hashGIDs(layout Layout) uint64 { - if f.seed == (maphash.Seed{}) { - f.seed = maphash.MakeSeed() - } - var h maphash.Hash - h.SetSeed(f.seed) - var b [4]byte - for _, g := range layout.Glyphs { - binary.LittleEndian.PutUint32(b[:], uint32(g.ID)) - h.Write(b[:]) - } - return h.Sum64() +// Layout a text according to a set of options. Results can be retrieved by +// iteratively calling NextGlyph. +func (l *Shaper) Layout(params Parameters, minWidth, maxWidth int, lc system.Locale, txt io.RuneReader) { + l.layoutText(params, minWidth, maxWidth, lc, txt, "") } -func (f *faceCache) shape(ppem fixed.Int26_6, layout Layout) clip.PathSpec { - if f == nil { - return clip.PathSpec{} - } - pk := pathKey{ - ppem: ppem, - gidHash: f.hashGIDs(layout), - } - if clip, ok := f.pathCache.Get(pk, layout); ok { - return clip - } - clip := f.face.Shape(ppem, layout) - f.pathCache.Put(pk, layout, clip) - return clip +// LayoutString is Layout for strings. +func (l *Shaper) LayoutString(params Parameters, minWidth, maxWidth int, lc system.Locale, str string) { + l.layoutText(params, minWidth, maxWidth, lc, nil, str) +} + +func (l *Shaper) reset(align Alignment) { + l.line, l.run, l.glyph, l.advance = 0, 0, 0, 0 + l.done = false + l.txt.reset() + l.txt.alignment = align +} + +// layoutText lays out a large text document by breaking it into paragraphs and laying +// out each of them separately. This allows the shaping results to be cached independently +// by paragraph. Only one of txt and str should be provided. +func (l *Shaper) layoutText(params Parameters, minWidth, maxWidth int, lc system.Locale, txt io.RuneReader, str string) { + l.reset(params.Alignment) + if txt == nil && len(str) == 0 { + l.txt.append(l.layoutParagraph(params, minWidth, maxWidth, lc, "", nil)) + return + } + var done bool + var startByte int + var endByte int + for !done { + var runes int + l.paragraph = l.paragraph[:0] + if txt != nil { + for r, _, re := txt.ReadRune(); !done; r, _, re = txt.ReadRune() { + if re != nil { + done = true + continue + } + l.paragraph = append(l.paragraph, r) + runes++ + if r == '\n' { + break + } + } + } else { + for endByte = startByte; endByte < len(str); { + r, width := utf8.DecodeRuneInString(str[endByte:]) + endByte += width + runes++ + if r == '\n' { + break + } + } + done = endByte == len(str) + } + l.txt.append(l.layoutParagraph(params, minWidth, maxWidth, lc, str[startByte:endByte], l.paragraph)) + if done { + return + } + startByte = endByte + } +} + +func (l *Shaper) layoutParagraph(params Parameters, minWidth, maxWidth int, lc system.Locale, asStr string, asRunes []rune) document { + if l == nil { + return document{} + } + if len(asStr) == 0 && len(asRunes) > 0 { + asStr = string(asRunes) + } + // Alignment is not part of the cache key because changing it does not impact shaping. + lk := layoutKey{ + ppem: params.PxPerEm, + maxWidth: maxWidth, + minWidth: minWidth, + maxLines: params.MaxLines, + str: asStr, + locale: lc, + font: params.Font, + } + if l, ok := l.layoutCache.Get(lk); ok { + return l + } + if len(asRunes) == 0 && len(asStr) > 0 { + asRunes = []rune(asStr) + } + lines := l.shaper.LayoutRunes(params, minWidth, maxWidth, lc, asRunes) + l.layoutCache.Put(lk, lines) + return lines +} + +// NextGlyph returns the next glyph from the most recent shaping operation, if +// any. If there are no more glyphs, ok will be false. +func (l *Shaper) NextGlyph() (_ Glyph, ok bool) { + if l.done { + return Glyph{}, false + } + for { + if l.line == len(l.txt.lines) { + if l.err == nil { + l.err = io.EOF + } + return Glyph{}, false + } + line := l.txt.lines[l.line] + if l.run == len(line.runs) { + l.line++ + l.run = 0 + continue + } + run := line.runs[l.run] + align := l.txt.alignment.Align(line.direction, line.width, l.txt.alignWidth) + if l.line == 0 && l.run == 0 && len(run.Glyphs) == 0 { + // The very first run is empty, which will only happen when the + // entire text is a shaped empty string. Return a single synthetic + // glyph to provide ascent/descent information to the caller. + l.done = true + return Glyph{ + X: align, + Y: int32(line.yOffset), + Runes: 0, + Flags: FlagLineBreak | FlagClusterBreak | FlagRunBreak | FlagSynthetic, + Ascent: line.ascent, + Descent: line.descent, + }, true + } + if l.glyph == len(run.Glyphs) { + l.run++ + l.glyph = 0 + l.advance = 0 + continue + } + glyphIdx := l.glyph + rtl := run.Direction.Progression() == system.TowardOrigin + if rtl { + // If RTL, traverse glyphs backwards to ensure rune order. + glyphIdx = len(run.Glyphs) - 1 - glyphIdx + } + g := run.Glyphs[glyphIdx] + if rtl { + // Modify the advance prior to computing runOffset to ensure that the + // current glyph's width is subtracted in RTL. + l.advance += g.xAdvance + } + // runOffset computes how far into the run the dot should be positioned. + runOffset := l.advance + if rtl { + runOffset = run.Advance - l.advance + } + glyph := Glyph{ + ID: g.id, + X: align + line.xOffset + run.X + runOffset, + Y: int32(line.yOffset), + Ascent: line.ascent, + Descent: line.descent, + Advance: g.xAdvance, + Runes: byte(g.runeCount), + Offset: fixed.Point26_6{ + X: g.xOffset, + Y: g.yOffset, + }, + Bounds: g.bounds, + } + l.glyph++ + if !rtl { + l.advance += g.xAdvance + } + + endOfRun := l.glyph == len(run.Glyphs) + if endOfRun { + glyph.Flags |= FlagRunBreak + } + endOfLine := endOfRun && l.run == len(line.runs)-1 + if endOfLine { + glyph.Flags |= FlagLineBreak + } + nextGlyph := l.glyph + if rtl { + nextGlyph = len(run.Glyphs) - 1 - nextGlyph + } + endOfCluster := endOfRun || run.Glyphs[nextGlyph].clusterIndex != g.clusterIndex + if endOfCluster { + glyph.Flags |= FlagClusterBreak + } else { + glyph.Runes = 0 + } + if run.Direction.Progression() == system.TowardOrigin { + glyph.Flags |= FlagTowardOrigin + } + if g.glyphCount == 0 { + glyph.Flags |= FlagSynthetic + } + + return glyph, true + } +} + +const ( + facebits = 16 + sizebits = 16 + gidbits = 64 - facebits - sizebits +) + +// newGlyphID encodes a face and a glyph id into a GlyphID. +func newGlyphID(ppem fixed.Int26_6, faceIdx int, gid font.GID) GlyphID { + if gid&^((1<> (gidbits + sizebits) + ppem := fixed.Int26_6((g & ((1<> gidbits) + gid := font.GID(g) & (1< end { start, end = end, start } - imeStart := e.closestPosition(combinedPos{runes: start}) - imeEnd := e.closestPosition(combinedPos{runes: end}) + imeStart := e.closestToRune(start) + imeEnd := e.closestToRune(end) e.ime.start = imeStart.runes e.ime.end = imeEnd.runes startOff := e.runeOffset(imeStart.runes) @@ -664,14 +659,22 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" - caret := e.closestPosition(combinedPos{runes: e.caret.start}) + caret := e.closestToRune(e.caret.start) switch { case caret.runes == 0 && caret.runes == e.Len(): keys = keyFilterNoArrows case caret.runes == 0: - keys = keyFilterNoLeftUp + if gtx.Locale.Direction.Progression() == system.FromOrigin { + keys = keyFilterNoLeftUp + } else { + keys = keyFilterNoRightDown + } case caret.runes == e.Len(): - keys = keyFilterNoRightDown + if gtx.Locale.Direction.Progression() == system.FromOrigin { + keys = keyFilterNoRightDown + } else { + keys = keyFilterNoLeftUp + } default: keys = keyFilterAllArrows } @@ -720,98 +723,60 @@ func (e *Editor) PaintSelection(gtx layout.Context) { if !e.focused { return } - cl := textPadding(e.lines) - cl.Max = cl.Max.Add(e.viewSize) - defer clip.Rect(cl).Push(gtx.Ops).Pop() - selStart, selEnd := e.caret.start, e.caret.end - if selStart > selEnd { - selStart, selEnd = selEnd, selStart - } - caretStart := e.closestPosition(combinedPos{runes: selStart}) - caretEnd := e.closestPosition(combinedPos{runes: selEnd}) - scroll := image.Point{ - X: e.scrollOff.X, - Y: e.scrollOff.Y, - } - cl = cl.Add(scroll) - pos := e.seekFirstVisibleLine(cl.Min.Y) - for !posIsBelow(e.lines, pos, cl.Max.Y) { - leftmost, rightmost := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos) - lineIdx := leftmost.lineCol.Y - if lineIdx < caretStart.lineCol.Y { - // Line is before selection start; skip. - pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}) - continue - } - if lineIdx > caretEnd.lineCol.Y { - // Line is after selection end; we're done. - return - } - line := e.lines[leftmost.lineCol.Y] - flip := line.Layout.Direction.Progression() == system.TowardOrigin - // Clamp start, end to selection. - if !flip { - if leftmost.runes < selStart { - leftmost = caretStart - } - if rightmost.runes > selEnd { - rightmost = caretEnd - } - } else { - if leftmost.runes > selEnd { - leftmost = caretEnd - } - if rightmost.runes < selStart { - rightmost = caretStart - } - } - - dotStart := image.Pt(leftmost.x.Round(), leftmost.y) - dotEnd := image.Pt(rightmost.x.Round(), rightmost.y) - t := op.Offset(scroll.Mul(-1)).Push(gtx.Ops) - size := image.Rectangle{ - Min: dotStart.Sub(image.Point{Y: line.Ascent.Ceil()}), - Max: dotEnd.Add(image.Point{Y: line.Descent.Ceil()}), - } - op := clip.Rect(size).Push(gtx.Ops) + localViewport := image.Rectangle{Max: e.viewSize} + docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff) + defer clip.Rect(localViewport).Push(gtx.Ops).Pop() + e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions) + for _, region := range e.regions { + area := clip.Rect(region.bounds.Sub(e.scrollOff)).Push(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) - op.Pop() - t.Pop() - - if pos.lineCol.Y == len(e.lines)-1 { - break - } - pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}) + area.Pop() } } func (e *Editor) PaintText(gtx layout.Context) { - cl := textPadding(e.lines) - cl.Max = cl.Max.Add(e.viewSize) - defer clip.Rect(cl).Push(gtx.Ops).Pop() - scroll := image.Point{ - X: e.scrollOff.X, - Y: e.scrollOff.Y, + var gs [32]text.Glyph + line := gs[:0] + var lineOff image.Point + m := op.Record(gtx.Ops) + viewport := image.Rectangle{ + Min: e.scrollOff, + Max: e.viewSize.Add(e.scrollOff), } - cl = cl.Add(scroll) - pos := e.seekFirstVisibleLine(cl.Min.Y) - for !posIsBelow(e.lines, pos, cl.Max.Y) { - start, end := clipLine(e.lines, e.Alignment, e.viewSize.X, cl, pos) - line := e.lines[start.lineCol.Y] - off := image.Point{X: start.x.Floor(), Y: start.y}.Sub(scroll) - l := subLayout(line, start, end) + it := textIterator{viewport: viewport} - t := op.Offset(off).Push(gtx.Ops) - op := clip.Outline{Path: e.shaper.Shape(e.font, e.textSize, l)}.Op().Push(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - op.Pop() - t.Pop() - - if pos.lineCol.Y == len(e.lines)-1 { + startGlyph := 0 + for _, line := range e.index.lines { + if line.descent.Ceil()+line.yOff >= viewport.Min.Y { break } - pos = e.closestPosition(combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}) + startGlyph += line.glyphs } + for _, g := range e.index.glyphs[startGlyph:] { + if !it.Glyph(g, true) { + break + } + if it.visible { + if len(line) == 0 { + lineOff = image.Point{X: it.g.X.Floor(), Y: int(it.g.Y)}.Sub(e.scrollOff) + } + line = append(line, g) + } + if g.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 { + t := op.Offset(lineOff).Push(gtx.Ops) + op := clip.Outline{Path: e.shaper.Shape(line)}.Op().Push(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + op.Pop() + t.Pop() + line = line[:0] + } + + } + call := m.Stop() + viewport.Min = viewport.Min.Add(it.padding.Min) + viewport.Max = viewport.Max.Add(it.padding.Max) + defer clip.Rect(viewport.Sub(e.scrollOff)).Push(gtx.Ops).Pop() + call.Add(gtx.Ops) } // caretWidth returns the width occupied by the caret for the current @@ -835,15 +800,7 @@ func (e *Editor) PaintCaret(gtx layout.Context) { Min: caretPos.Sub(image.Pt(carWidth2, carAsc)), Max: caretPos.Add(image.Pt(carWidth2, carDesc)), } - cl := textPadding(e.lines) - // Account for caret width to each side. - if cl.Max.X < carWidth2 { - cl.Max.X = carWidth2 - } - if cl.Min.X > -carWidth2 { - cl.Min.X = -carWidth2 - } - cl.Max = cl.Max.Add(e.viewSize) + cl := image.Rectangle{Max: e.viewSize} carRect = cl.Intersect(carRect) if !carRect.Empty() { defer clip.Rect(carRect).Push(gtx.Ops).Pop() @@ -851,44 +808,24 @@ func (e *Editor) PaintCaret(gtx layout.Context) { } } -func (e *Editor) seekFirstVisibleLine(y int) combinedPos { - pos := e.closestPosition(combinedPos{y: y}) - for pos.lineCol.Y > 0 { - prevLine := pos.lineCol.Y - 1 - prev := e.closestPosition(combinedPos{lineCol: screenPos{Y: prevLine}}) - if posIsAbove(e.lines, prev, y) { - break - } - pos = prev - } - return pos -} - func (e *Editor) caretInfo() (pos image.Point, ascent, descent int) { - caretStart := e.closestPosition(combinedPos{runes: e.caret.start}) - carX := caretStart.x - carY := caretStart.y + caretStart := e.closestToRune(e.caret.start) + + ascent = caretStart.ascent.Ceil() + descent = caretStart.descent.Ceil() - ascent = -e.lines[caretStart.lineCol.Y].Bounds.Min.Y.Ceil() - descent = e.lines[caretStart.lineCol.Y].Bounds.Max.Y.Ceil() pos = image.Point{ - X: carX.Round(), - Y: carY, + X: caretStart.x.Round(), + Y: caretStart.y, } pos = pos.Sub(e.scrollOff) return } -// TODO: copied from package math. Remove when Go 1.18 is minimum. -const ( - intSize = 32 << (^uint(0) >> 63) // 32 or 64 - maxInt = 1<<(intSize-1) - 1 -) - // Len is the length of the editor contents, in runes. func (e *Editor) Len() int { - end := e.closestPosition(combinedPos{runes: maxInt}) - return end.runes + e.makeValid() + return e.closestToRune(math.MaxInt).runes } // Text returns the contents of the editor. @@ -911,8 +848,9 @@ func (e *Editor) SetText(s string) { func (e *Editor) scrollBounds() image.Rectangle { var b image.Rectangle if e.SingleLine { - if len(e.lines) > 0 { - b.Min.X = align(e.Alignment, e.locale.Direction, e.lines[0].Width, e.viewSize.X).Floor() + if len(e.index.lines) > 0 { + line := e.index.lines[0] + b.Min.X = line.xOff.Floor() if b.Min.X > 0 { b.Min.X = 0 } @@ -949,234 +887,51 @@ func (e *Editor) scrollAbs(x, y int) { func (e *Editor) moveCoord(pos image.Point) { x := fixed.I(pos.X + e.scrollOff.X) y := pos.Y + e.scrollOff.Y - e.caret.start = e.closestPosition(combinedPos{x: x, y: y}).runes + e.caret.start = e.closestToXY(x, y).runes e.caret.xoff = 0 } -func (e *Editor) layoutText(s text.Shaper) ([]text.Line, layout.Dimensions) { +func (e *Editor) layoutText(lt *text.Shaper) { e.rr.Reset() var r io.RuneReader = &e.rr if e.Mask != 0 { e.maskReader.Reset(&e.rr, e.Mask) r = &e.maskReader } - var lines []text.Line - if s != nil { - lines, _ = s.Layout(e.font, e.textSize, e.maxWidth, e.locale, r) - if len(lines) == 0 { - // The editor does not tolerate a zero-length list of lines being returned from the shaper. - lines = append(lines, text.Line{}) + e.index = glyphIndex{} + it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}} + if lt != nil { + lt.Layout(text.Parameters{ + Font: e.font, + PxPerEm: e.textSize, + Alignment: e.Alignment, + }, 0, e.maxWidth, e.locale, r) + for it.Glyph(lt.NextGlyph()) { + e.index.Glyph(it.g) } } else { - lines, _ = nullLayout(r) + // Make a fake glyph for every rune in the reader. + for _, _, err := r.ReadRune(); err != io.EOF; _, _, err = r.ReadRune() { + it.Glyph(text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}, true) + e.index.Glyph(it.g) + } } - dims := linesDimens(lines) - return lines, dims + dims := layout.Dimensions{Size: it.bounds.Size()} + dims.Baseline = dims.Size.Y - it.baseline + e.dims = dims } // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) - return caret.lineCol.Y, caret.lineCol.X + pos := e.closestToRune(e.caret.start) + return pos.lineCol.line, pos.lineCol.col } // CaretCoords returns the coordinates of the caret, relative to the // editor itself. func (e *Editor) CaretCoords() f32.Point { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) - return f32.Pt(float32(caret.x)/64-float32(e.scrollOff.X), float32(caret.y-e.scrollOff.Y)) -} - -// indexPosition returns the latest position from the index no later than pos. -func (e *Editor) indexPosition(pos combinedPos) combinedPos { - e.makeValid() - // Initialize index with first caret position. - if len(e.index) == 0 { - e.index = append(e.index, firstPos(e.lines[0], e.Alignment, e.viewSize.X)) - } - i := sort.Search(len(e.index), func(i int) bool { - return positionGreaterOrEqual(e.lines, e.index[i], pos) - }) - // Return position just before pos, which is guaranteed to be less than or equal to pos. - if i > 0 { - i-- - } - return e.index[i] -} - -// positionGreaterOrEqual reports whether p1 >= p2 according to the non-zero fields -// of p2. All fields of p1 must be consistent and valid. -func positionGreaterOrEqual(lines []text.Line, p1, p2 combinedPos) bool { - l := lines[p1.lineCol.Y] - // Check whether the final glyph cluster has no glyphs, indicating a newline - // rune that forced the existence of a line break. - hardNewLine := len(l.Layout.Clusters) > 0 && l.Layout.Clusters[len(l.Layout.Clusters)-1].Glyphs.Count == 0 - endCol := l.Layout.Runes.Count - if hardNewLine { - // If there was a hard newline, prevent the cursor for passing it on - // this line. - endCol-- - } - eol := p1.lineCol.X == endCol - switch { - case p2.runes != 0: - return p1.runes >= p2.runes - case p2.lineCol != (screenPos{}): - if p1.lineCol.Y != p2.lineCol.Y { - return p1.lineCol.Y > p2.lineCol.Y - } - return eol || p1.lineCol.X >= p2.lineCol.X - case p2.x != 0 || p2.y != 0: - ly := p1.y + l.Descent.Ceil() - prevy := p1.y - l.Ascent.Ceil() - switch { - case ly < p2.y && p1.lineCol.Y < len(lines)-1: - // p1 is on a line before p2.y. - return false - case prevy >= p2.y && p1.lineCol.Y > 0: - // p1 is on a line after p2.y. - return true - } - if eol { - return true - } - // If clusterIndex is equal, they could be positions of different - // runes within the same cluster, so we fall back to the positional - // test below. - if p2.clusterIndex != 0 && p1.clusterIndex != p2.clusterIndex { - return p1.clusterIndex > p2.clusterIndex - } - - flip := l.Layout.Direction.Progression() == system.TowardOrigin - if p1.clusterIndex == len(l.Layout.Clusters) { - return (!flip && p1.x >= p2.x) || (flip && p1.x <= p2.x) - } else { - adv := l.Layout.Clusters[p1.clusterIndex].RuneWidth() - - left := p1.x + adv - p2.x - right := p2.x - p1.x - - return (!flip && left >= right) || (flip && left <= right) - } - } - return true -} - -// clusterIndexFor returns the index of the glyph cluster containing the rune -// at the given position within the line. As a special case, if the rune is one -// beyond the final rune of the line, it returns the length of the line's clusters -// slice. Otherwise, it panics if given a rune beyond the -// dimensions of the line. The startIdx must be known to be at or before -// the real index of the cluster. This means that this function is -// only useful for searching forward through text, not backward. -// Passing a startIdx after the cluster corresponding to the runeIdx -// will trigger a panic. -// -// All indices are relative to the content in the line. The runeIdx 0 -// refers to the first rune on the line, regardless of the line's -// rune offset. Similarly, the provided and returned glyph cluster -// indices are relative to the line's cluster slice. -func clusterIndexFor(line text.Line, runeIdx, startIdx int) int { - if runeIdx == line.Layout.Runes.Count { - return len(line.Layout.Clusters) - } - lineStart := line.Layout.Runes.Offset - for i := startIdx; i < len(line.Layout.Clusters); i++ { - cluster := line.Layout.Clusters[i] - clusterStart := cluster.Runes.Offset - lineStart - clusterEnd := clusterStart + cluster.Runes.Count - if runeIdx >= clusterStart && runeIdx < clusterEnd { - return i - } - } - panic(fmt.Errorf("requested cluster index for rune %d outside of line with %d runes", runeIdx, line.Layout.Runes.Count)) -} - -// closestPosition takes a position and returns its closest valid position. -// Zero fields of pos are ignored. -func (e *Editor) closestPosition(pos combinedPos) combinedPos { - closest := e.indexPosition(pos) - const runesPerIndexEntry = 50 - for { - var done bool - closest, done = seekPosition(e.lines, e.Alignment, e.viewSize.X, closest, pos, runesPerIndexEntry) - if done { - return closest - } - e.index = append(e.index, closest) - } -} - -// seekPosition seeks to the position closest to needle, starting at start and returns true. -// If limit is non-zero, seekPosition stops seeks after limit runes and returns false. -// Start must have all fields valid, and needle must have at least one of runes, lineCol, -// or x+y valid. Start must be known to be before needle. -func seekPosition(lines []text.Line, alignment text.Alignment, width int, start, needle combinedPos, limit int) (combinedPos, bool) { - count := 0 - // Advance until start is greater than or equal to needle. - for { - if positionGreaterOrEqual(lines, start, needle) { - return start, true - } - var eof bool - start, eof = incrementPosition(lines, alignment, width, start) - if eof { - return start, true - } - count++ - if limit != 0 && count == limit { - return start, false - } - } -} - -// incrementLinePosition transitions pos from the end of a line to the beginning of the next one. -// If pos is not at the end of a line, it will have no effect. It returns the (possibly modified) -// position, whether it handled the transition from the end of a line, and whether the position -// is at the end of the text data. -func incrementLinePosition(lines []text.Line, alignment text.Alignment, width int, pos combinedPos) (_ combinedPos, eol, eof bool) { - l := lines[pos.lineCol.Y] - if pos.lineCol.X >= l.Layout.Runes.Count { - if pos.lineCol.Y == len(lines)-1 { - // End of file. - return pos, false, true - } - // Move to next line. - prevDesc := l.Descent - pos.lineCol.Y++ - pos.lineCol.X = 0 - pos.clusterIndex = 0 - l = lines[pos.lineCol.Y] - // Use firstPos to get the correct x coordinate of the beginning of the line. - alignedPos := firstPos(l, alignment, width) - pos.x = alignedPos.x - pos.y += (prevDesc + l.Ascent).Ceil() - return pos, true, false - } - return pos, false, false -} - -// incrementPosition updates pos to be one position further into the text. This will either -// move pos one rune further into the text, transition pos from the end of one line to the -// beginning of the next, or both. -// All fields of pos must be valid before calling incrementPosition. eof will be true when -// pos represents the final text position in the lines. -func incrementPosition(lines []text.Line, alignment text.Alignment, width int, pos combinedPos) (_ combinedPos, eof bool) { - var eol bool - pos, eol, eof = incrementLinePosition(lines, alignment, width, pos) - if eof || eol { - return pos, eof - } - l := lines[pos.lineCol.Y] - isHardNewLine := l.Layout.Clusters[pos.clusterIndex].Glyphs.Count == 0 - pos.x += l.Layout.Clusters[pos.clusterIndex].RuneWidth() - pos.runes++ - pos.lineCol.X++ - pos.clusterIndex = clusterIndexFor(l, pos.lineCol.X, pos.clusterIndex) - if isHardNewLine { - pos, _, eof = incrementLinePosition(lines, alignment, width, pos) - } - return pos, false + pos := e.closestToRune(e.caret.start) + return f32.Pt(float32(pos.x)/64-float32(e.scrollOff.X), float32(pos.y-e.scrollOff.Y)) } // indexRune returns the latest rune index and byte offset no later than r. @@ -1214,7 +969,6 @@ func (e *Editor) runeOffset(r int) int { } func (e *Editor) invalidate() { - e.index = e.index[:0] e.offIndex = e.offIndex[:0] e.valid = false } @@ -1315,8 +1069,8 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int { if start > end { start, end = end, start } - startPos := e.closestPosition(combinedPos{runes: start}) - endPos := e.closestPosition(combinedPos{runes: end}) + startPos := e.closestToRune(start) + endPos := e.closestToRune(end) startOff := e.runeOffset(startPos.runes) replaceSize := endPos.runes - startPos.runes el := e.Len() @@ -1376,10 +1130,10 @@ func (e *Editor) replace(start, end int, s string, addHistory bool) int { } func (e *Editor) movePages(pages int, selAct selectionAction) { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) + caret := e.closestToRune(e.caret.start) x := caret.x + e.caret.xoff y := caret.y + pages*e.viewSize.Y - pos := e.closestPosition(combinedPos{x: x, y: y}) + pos := e.closestToXY(x, y) e.caret.start = pos.runes e.caret.xoff = x - pos.x e.updateSelection(selAct) @@ -1390,25 +1144,23 @@ func (e *Editor) movePages(pages int, selAct selectionAction) { // negative distances moves backward. Distances are in runes. func (e *Editor) MoveCaret(startDelta, endDelta int) { e.caret.xoff = 0 - e.caret.start = e.closestPosition(combinedPos{runes: e.caret.start + startDelta}).runes - e.caret.end = e.closestPosition(combinedPos{runes: e.caret.end + endDelta}).runes + e.caret.start = e.closestToRune(e.caret.start + startDelta).runes + e.caret.end = e.closestToRune(e.caret.end + endDelta).runes } func (e *Editor) moveStart(selAct selectionAction) { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) - caret = e.closestPosition(combinedPos{lineCol: screenPos{Y: caret.lineCol.Y}}) + caret := e.closestToRune(e.caret.start) + caret = e.closestToLineCol(caret.lineCol.line, 0) e.caret.start = caret.runes e.caret.xoff = -caret.x e.updateSelection(selAct) } func (e *Editor) moveEnd(selAct selectionAction) { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) - caret = e.closestPosition(combinedPos{lineCol: screenPos{X: maxInt, Y: caret.lineCol.Y}}) + caret := e.closestToRune(e.caret.start) + caret = e.closestToLineCol(caret.lineCol.line, math.MaxInt) e.caret.start = caret.runes - l := e.lines[caret.lineCol.Y] - a := align(e.Alignment, e.locale.Direction, l.Width, e.viewSize.X) - e.caret.xoff = l.Width + a - caret.x + e.caret.xoff = fixed.I(e.maxWidth) - caret.x e.updateSelection(selAct) } @@ -1423,7 +1175,7 @@ func (e *Editor) moveWord(distance int, selAct selectionAction) { words, direction = distance*-1, -1 } // atEnd if caret is at either side of the buffer. - caret := e.closestPosition(combinedPos{runes: e.caret.start}) + caret := e.closestToRune(e.caret.start) atEnd := func() bool { return caret.runes == 0 || caret.runes == e.Len() } @@ -1440,13 +1192,13 @@ func (e *Editor) moveWord(distance int, selAct selectionAction) { for ii := 0; ii < words; ii++ { for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() { e.MoveCaret(direction, 0) - caret = e.closestPosition(combinedPos{runes: e.caret.start}) + caret = e.closestToRune(e.caret.start) } e.MoveCaret(direction, 0) - caret = e.closestPosition(combinedPos{runes: e.caret.start}) + caret = e.closestToRune(e.caret.start) for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() { e.MoveCaret(direction, 0) - caret = e.closestPosition(combinedPos{runes: e.caret.start}) + caret = e.closestToRune(e.caret.start) } } e.updateSelection(selAct) @@ -1477,7 +1229,7 @@ func (e *Editor) deleteWord(distance int) { words, direction = distance*-1, -1 } // atEnd if offset is at or beyond either side of the buffer. - caret := e.closestPosition(combinedPos{runes: e.caret.start}) + caret := e.closestToRune(e.caret.start) atEnd := func(runes int) bool { idx := caret.runes + runes*direction return idx <= 0 || idx >= e.Len() @@ -1511,8 +1263,7 @@ func (e *Editor) deleteWord(distance int) { } func (e *Editor) scrollToCaret() { - caret := e.closestPosition(combinedPos{runes: e.caret.start}) - l := e.lines[caret.lineCol.Y] + caret := e.closestToRune(e.caret.start) if e.SingleLine { var dist int if d := caret.x.Floor() - e.scrollOff.X; d < 0 { @@ -1522,8 +1273,8 @@ func (e *Editor) scrollToCaret() { } e.scrollRel(dist, 0) } else { - miny := caret.y - l.Ascent.Ceil() - maxy := caret.y + l.Descent.Ceil() + miny := caret.y - caret.ascent.Ceil() + maxy := caret.y + caret.descent.Ceil() var dist int if d := miny - e.scrollOff.Y; d < 0 { dist = d @@ -1534,12 +1285,6 @@ func (e *Editor) scrollToCaret() { } } -// NumLines returns the number of lines in the editor. -func (e *Editor) NumLines() int { - e.makeValid() - return len(e.lines) -} - // SelectionLen returns the length of the selection, in runes; it is // equivalent to utf8.RuneCountInString(e.SelectedText()). func (e *Editor) SelectionLen() int { @@ -1555,8 +1300,8 @@ func (e *Editor) Selection() (start, end int) { // SetCaret moves the caret to start, and sets the selection end to end. start // and end are in runes, and represent offsets into the editor text. func (e *Editor) SetCaret(start, end int) { - e.caret.start = e.closestPosition(combinedPos{runes: start}).runes - e.caret.end = e.closestPosition(combinedPos{runes: end}).runes + e.caret.start = e.closestToRune(start).runes + e.caret.end = e.closestToRune(end).runes e.caret.scroll = true e.scroller.Stop() } @@ -1612,14 +1357,14 @@ func max(a, b int) int { return b } -func min(a, b int) int { +func min[T constraints.Ordered](a, b T) T { if a < b { return a } return b } -func abs(n int) int { +func abs[T constraints.Signed](n T) T { if n < 0 { return -n } @@ -1637,36 +1382,6 @@ func sign(n int) int { } } -func nullLayout(rr io.RuneReader) ([]text.Line, error) { - var rerr error - var n int - var buf bytes.Buffer - for { - r, _, err := rr.ReadRune() - if err != nil { - rerr = err - break - } - n++ - buf.WriteRune(r) - } - clusters := make([]text.GlyphCluster, n) - for i := range clusters { - clusters[i].Runes.Count = 1 - clusters[i].Runes.Offset = i - } - return []text.Line{ - { - Layout: text.Layout{ - Clusters: clusters, - Runes: text.Range{ - Count: n, - }, - }, - }, - }, rerr -} - func (s ChangeEvent) isEditorEvent() {} func (s SubmitEvent) isEditorEvent() {} func (s SelectEvent) isEditorEvent() {} diff --git a/widget/editor_test.go b/widget/editor_test.go index 3f4caf3f..d82a4355 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -9,11 +9,9 @@ import ( "io" "math/rand" "reflect" - "strings" "testing" "testing/quick" "time" - "unicode" "unicode/utf8" nsareg "eliasnaur.com/font/noto/sans/arabic/regular" @@ -29,7 +27,6 @@ import ( "gioui.org/op" "gioui.org/text" "gioui.org/unit" - "golang.org/x/image/math/fixed" ) var english = system.Locale{ @@ -105,7 +102,7 @@ func TestEditorZeroDimensions(t *testing.T) { }, Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e := new(Editor) @@ -121,7 +118,7 @@ func TestEditorConfigurations(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog" @@ -161,7 +158,7 @@ func TestEditor(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} @@ -209,35 +206,40 @@ func TestEditor(t *testing.T) { assertCaret(t, e, 1, 1, len("æbc\na")) e.MoveCaret(-3, -3) assertCaret(t, e, 0, 2, len("æb")) - e.Mask = '\U0001F92B' - e.Layout(gtx, cache, font, fontSize, nil) - e.moveEnd(selectionClear) - assertCaret(t, e, 0, 3, len("æbc")) + /* + NOTE(whereswaldon): it isn't possible to check the raw glyph data + like this anymore. How should we handle this? + e.Mask = '\U0001F92B' + e.Layout(gtx, cache, font, fontSize, nil) + e.moveEnd(selectionClear) + assertCaret(t, e, 0, 3, len("æbc")) - // When a password mask is applied, it should replace all visible glyphs - spaces := 0 - for _, r := range textSample { - if unicode.IsSpace(r) { - spaces++ - } - } - nonSpaces := len([]rune(textSample)) - spaces - glyphCounts := make(map[int]int) - for _, line := range e.lines { - for _, glyph := range line.Layout.Glyphs { - glyphCounts[int(glyph.ID)]++ - } - } - if len(glyphCounts) > 2 { - t.Errorf("masked text contained glyphs other than mask and whitespace") - } + // When a password mask is applied, it should replace all visible glyphs + spaces := 0 + for _, r := range textSample { + if unicode.IsSpace(r) { + spaces++ + } + } + nonSpaces := len([]rune(textSample)) - spaces + glyphCounts := make(map[int]int) + // This loop assumes a single-run text, which we know is safe here. + for _, line := range e.lines { + for _, glyph := range line.Runs[0].Glyphs { + glyphCounts[int(glyph.ID)]++ + } + } + if len(glyphCounts) > 2 { + t.Errorf("masked text contained glyphs other than mask and whitespace") + } - for gid, count := range glyphCounts { - if count != spaces && count != nonSpaces { - t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces) - } - } + for gid, count := range glyphCounts { + if count != spaces && count != nonSpaces { + t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces) + } + } + */ // Test that moveLine applies x offsets from previous moves. e.SetText("long line\nshort") e.SetCaret(0, 0) @@ -264,7 +266,7 @@ func TestEditorRTL(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: arabic, } - cache := text.NewCache(arabicCollection) + cache := text.NewShaper(arabicCollection) fontSize := unit.Sp(10) font := text.Font{} @@ -333,7 +335,7 @@ func TestEditorLigature(t *testing.T) { if err != nil { t.Skipf("failed parsing test font: %v", err) } - cache := text.NewCache([]text.FontFace{ + cache := text.NewShaper([]text.FontFace{ { Font: text.Font{ Typeface: "Roboto", @@ -454,7 +456,7 @@ func TestEditorDimensions(t *testing.T) { Queue: tq, Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} dims := e.Layout(gtx, cache, font, fontSize, nil) @@ -501,7 +503,7 @@ func TestEditorCaretConsistency(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} for _, a := range []text.Alignment{text.Start, text.Middle, text.End} { @@ -516,11 +518,11 @@ func TestEditorCaretConsistency(t *testing.T) { gotCoords := e.CaretCoords() // Blow away index to re-compute position from scratch. e.invalidate() - want := e.closestPosition(combinedPos{runes: e.caret.start}) + want := e.closestToRune(e.caret.start) wantCoords := f32.Pt(float32(want.x)/64, float32(want.y)) - if want.lineCol.Y != gotLine || want.lineCol.X != gotCol || gotCoords != wantCoords { + if want.lineCol.line != gotLine || int(want.lineCol.col) != gotCol || gotCoords != wantCoords { return fmt.Errorf("caret (%d,%d) pos %s, want (%d,%d) pos %s", - gotLine, gotCol, gotCoords, want.lineCol.Y, want.lineCol.X, wantCoords) + gotLine, gotCol, gotCoords, want.lineCol.line, want.lineCol.col, wantCoords) } return nil } @@ -594,7 +596,7 @@ func TestEditorMoveWord(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.SetText(t) @@ -699,7 +701,7 @@ func TestEditorInsert(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.SetText(t) @@ -789,7 +791,7 @@ func TestEditorDeleteWord(t *testing.T) { Constraints: layout.Exact(image.Pt(100, 100)), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.SetText(t) @@ -823,12 +825,12 @@ func (editMutation) Generate(rand *rand.Rand, size int) reflect.Value { return reflect.ValueOf(t) } -// TestSelect tests the selection code. It lays out an editor with several +// TestEditorSelect tests the selection code. It lays out an editor with several // lines in it, selects some text, verifies the selection, resizes the editor // to make it much narrower (which makes the lines in the editor reflow), and // then verifies that the updated (col, line) positions of the selected text // are where we expect. -func TestSelect(t *testing.T) { +func TestEditorSelect(t *testing.T) { e := new(Editor) e.SetText(`a 2 4 6 8 a b 2 4 6 8 b @@ -843,7 +845,7 @@ g 2 4 6 8 g Ops: new(op.Ops), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) font := text.Font{} fontSize := unit.Sp(10) @@ -855,8 +857,8 @@ g 2 4 6 8 g _ = e.Events() // throw away any events from this layout // Build the selection events - startPos := e.closestPosition(combinedPos{runes: start}) - endPos := e.closestPosition(combinedPos{runes: end}) + startPos := e.closestToRune(start) + endPos := e.closestToRune(end) tq := &testQueue{ events: []event.Event{ pointer.Event{ @@ -864,13 +866,13 @@ g 2 4 6 8 g Type: pointer.Press, Source: pointer.Mouse, Time: tim, - Position: f32.Pt(textWidth(e, startPos.lineCol.Y, 0, startPos.lineCol.X), textHeight(e, startPos.lineCol.Y)), + Position: f32.Pt(textWidth(e, startPos.lineCol.line, 0, startPos.lineCol.col), textBaseline(e, startPos.lineCol.line)), }, pointer.Event{ Type: pointer.Release, Source: pointer.Mouse, Time: tim, - Position: f32.Pt(textWidth(e, endPos.lineCol.Y, 0, endPos.lineCol.X), textHeight(e, endPos.lineCol.Y)), + Position: f32.Pt(textWidth(e, endPos.lineCol.line, 0, endPos.lineCol.col), textBaseline(e, endPos.lineCol.line)), }, }, } @@ -886,6 +888,15 @@ g 2 4 6 8 g } return "" } + type screenPos image.Point + logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) { + t.Helper() + if actual.lineCol.line != expected.Y || actual.lineCol.col != expected.X { + t.Errorf("Test %d: Expected %s %#v; got %#v", + n, label, + expected, actual) + } + } type testCase struct { // input text offsets @@ -916,15 +927,10 @@ g 2 4 6 8 g gtx.Queue = nil e.Layout(gtx, cache, font, fontSize, nil) - caretStart := e.closestPosition(combinedPos{runes: e.caret.start}) - caretEnd := e.closestPosition(combinedPos{runes: e.caret.end}) - if caretEnd.lineCol != tst.startPos || caretStart.lineCol != tst.endPos { - t.Errorf("Test %d pt2: Expected %#v, %#v; got %#v, %#v", - n, - caretEnd.lineCol, caretStart.lineCol, - tst.startPos, tst.endPos) - continue - } + caretStart := e.closestToRune(e.caret.start) + caretEnd := e.closestToRune(e.caret.end) + logicalPosMatch(t, n, "start", tst.startPos, caretEnd) + logicalPosMatch(t, n, "end", tst.endPos, caretStart) } } @@ -937,7 +943,7 @@ func TestSelectMove(t *testing.T) { Ops: new(op.Ops), Locale: english, } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) font := text.Font{} fontSize := unit.Sp(10) @@ -1025,7 +1031,7 @@ func TestEditor_MaxLen(t *testing.T) { key.SelectionEvent{Start: 4, End: 4}, ), } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.Layout(gtx, cache, font, fontSize, nil) @@ -1056,7 +1062,7 @@ func TestEditor_Filter(t *testing.T) { key.SelectionEvent{Start: 4, End: 4}, ), } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.Layout(gtx, cache, font, fontSize, nil) @@ -1080,7 +1086,7 @@ func TestEditor_Submit(t *testing.T) { key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"}, ), } - cache := text.NewCache(gofont.Collection()) + cache := text.NewShaper(gofont.Collection()) fontSize := unit.Sp(10) font := text.Font{} e.Layout(gtx, cache, font, fontSize, nil) @@ -1098,26 +1104,24 @@ func TestEditor_Submit(t *testing.T) { } } +// textWidth is a text helper for building simple selection events. +// It assumes single-run lines, which isn't safe with non-test text +// data. func textWidth(e *Editor, lineNum, colStart, colEnd int) float32 { - var w fixed.Int26_6 - glyphs := e.lines[lineNum].Layout.Glyphs - if colEnd > len(glyphs) { - colEnd = len(glyphs) + start := e.closestToLineCol(lineNum, colStart) + end := e.closestToLineCol(lineNum, colEnd) + delta := start.x - end.x + if delta < 0 { + delta = -delta } - for _, glyph := range glyphs[colStart:colEnd] { - w += glyph.XAdvance - } - return float32(w.Floor()) + return float32(delta.Round()) } -func textHeight(e *Editor, lineNum int) float32 { - var h int - var prevDesc fixed.Int26_6 - for _, line := range e.lines[0:lineNum] { - h += (line.Ascent + prevDesc).Ceil() - prevDesc = line.Descent - } - return float32(h + prevDesc.Ceil() + 1) +// testBaseline returns the y coordinate of the baseline for the +// given line number. +func textBaseline(e *Editor, lineNum int) float32 { + start := e.closestToLineCol(lineNum, 0) + return float32(start.y) } type testQueue struct { @@ -1131,16 +1135,3 @@ func newQueue(e ...event.Event) *testQueue { func (q *testQueue) Events(_ event.Tag) []event.Event { return q.events } - -func printLines(e *Editor) { - for _, line := range e.lines { - start := e.runeOffset(line.Layout.Runes.Offset) - buf := make([]byte, 0, 4*line.Layout.Runes.Count) - e.Seek(int64(start), 0) - n, _ := e.Read(buf) - buf = buf[:n] - asStr := string([]rune(string(buf))[:line.Layout.Runes.Count]) - text := strings.TrimSuffix(asStr, "\n") - fmt.Printf("%d: %s\n", n, text) - } -} diff --git a/widget/index.go b/widget/index.go new file mode 100644 index 00000000..9d070fd3 --- /dev/null +++ b/widget/index.go @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package widget + +import ( + "image" + "math" + "sort" + + "gioui.org/text" + "golang.org/x/image/math/fixed" +) + +type lineInfo struct { + xOff fixed.Int26_6 + yOff int + width fixed.Int26_6 + ascent, descent fixed.Int26_6 + glyphs int +} + +type glyphIndex struct { + glyphs []text.Glyph + // positions contain all possible caret positions, sorted by rune index. + positions []combinedPos + // lines contains metadata about the size and position of each line of + // text. + lines []lineInfo + + // currentLineMin and currentLineMax track the dimensions of the line + // that is being indexed. + currentLineMin, currentLineMax fixed.Int26_6 + // currentLineGlyphs tracks how many glyphs are contained within the + // line that is being indexed. + currentLineGlyphs int + // pos tracks attributes of the next valid cursor position within the indexed + // text. + pos combinedPos + // prog tracks the current glyph text progression to detect bidi changes. + prog text.Flags + // clusterAdvance accumulates the advances of glyphs in a glyph cluster. + clusterAdvance fixed.Int26_6 + // skipPrior controls whether a text position is inserted "before" the + // next glyph. Usually this should not happen, but the boundaries of + // lines and bidi runs require it. + skipPrior bool +} + +// screenPos represents a character position in text line and column numbers, +// not pixels. +type screenPos struct { + // col is the column, measured in runes. + // FIXME: we only ever use col for start or end of lines. + // We don't need accurate accounting, so can we get rid of it? + col int + line int +} + +// combinedPos is a point in the editor. +type combinedPos struct { + // runes is the offset in runes. + runes int + + lineCol screenPos + + // Pixel coordinates + x fixed.Int26_6 + y int + + ascent, descent fixed.Int26_6 + + // runIndex tracks which run this position is within, counted each time + // the index processes an end of run marker. + runIndex int + // towardOrigin tracks whether this glyph's run is progressing toward the + // origin or away from it. + towardOrigin bool +} + +// incrementPosition returns the next position after pos (if any). Pos _must_ be +// an unmodified position acquired from one of the closest* methods. If eof is +// true, there was no next position. +func (g *glyphIndex) incrementPosition(pos combinedPos) (next combinedPos, eof bool) { + candidate, index := g.closestToRune(pos.runes) + for candidate != pos && index+1 < len(g.positions) { + index++ + candidate = g.positions[index] + } + if index+1 < len(g.positions) { + return g.positions[index+1], false + } + return candidate, true + +} + +// Glyph indexes the provided glyph, generating text cursor positions for it. +func (g *glyphIndex) Glyph(gl text.Glyph) { + g.glyphs = append(g.glyphs, gl) + g.currentLineGlyphs++ + if len(g.positions) == 0 { + // First-iteration setup. + g.currentLineMin = math.MaxInt32 + g.currentLineMax = 0 + } + if gl.X < g.currentLineMin { + g.currentLineMin = gl.X + } + if end := gl.X + gl.Advance; end > g.currentLineMax { + g.currentLineMax = end + } + if !g.skipPrior || gl.Flags&text.FlagTowardOrigin != g.prog { + // Set the new text progression based on that of the first glyph. + g.prog = gl.Flags & text.FlagTowardOrigin + g.pos.towardOrigin = g.prog == text.FlagTowardOrigin + // Create the text position prior to the first glyph. + pos := g.pos + pos.x = gl.X + pos.y = int(gl.Y) + pos.ascent = gl.Ascent + pos.descent = gl.Descent + if pos.towardOrigin { + pos.x += gl.Advance + } + g.pos = pos + g.positions = append(g.positions, pos) + g.skipPrior = true + } + needsNewLine := gl.Flags&text.FlagLineBreak > 0 + needsNewRun := gl.Flags&text.FlagRunBreak > 0 + + // We should insert new positions if the glyph we're processing terminates + // a glyph cluster. + insertPositionAfter := gl.Flags&text.FlagClusterBreak > 0 + if gl.Flags&text.FlagSynthetic > 0 { + // Synthetic clusters shouldn't have positions generated for both + // sides of them. They're always zero-width, so doing so would + // create two visually identical cursor positions. Just reset + // cluster state, increment by their runes, and move on to the + // next glyph. + g.clusterAdvance = 0 + g.pos.runes += int(gl.Runes) + insertPositionAfter = false + } + // Always track the cumulative advance added by the glyph, even if it + // doesn't terminate a cluster itself. + g.clusterAdvance += gl.Advance + if insertPositionAfter { + // Construct the text position _after_ gl. + pos := g.pos + pos.y = int(gl.Y) + pos.ascent = gl.Ascent + pos.descent = gl.Descent + width := g.clusterAdvance + perRune := width / fixed.Int26_6(gl.Runes) + adjust := fixed.Int26_6(0) + if pos.towardOrigin { + // If RTL, subtract increments from the width of the cluster + // instead of adding. + adjust = width + perRune = -perRune + } + for i := 1; i <= int(gl.Runes); i++ { + pos.x = gl.X + adjust + perRune*fixed.Int26_6(i) + pos.runes++ + pos.lineCol.col++ + g.positions = append(g.positions, pos) + } + g.pos = pos + g.clusterAdvance = 0 + } + if needsNewRun { + g.pos.runIndex++ + } + if needsNewLine { + g.lines = append(g.lines, lineInfo{ + xOff: g.currentLineMin, + yOff: int(gl.Y), + width: g.currentLineMax - g.currentLineMin, + ascent: g.positions[len(g.positions)-1].ascent, + descent: g.positions[len(g.positions)-1].descent, + glyphs: g.currentLineGlyphs, + }) + g.pos.lineCol.line++ + g.pos.lineCol.col = 0 + g.pos.runIndex = 0 + g.currentLineMin = math.MaxInt32 + g.currentLineMax = 0 + g.currentLineGlyphs = 0 + g.skipPrior = false + } +} + +func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) { + if len(g.positions) == 0 { + return combinedPos{}, 0 + } + i := sort.Search(len(g.positions), func(i int) bool { + pos := g.positions[i] + return pos.runes >= runeIdx + }) + if i > 0 { + i-- + } + closest := g.positions[i] + closestI := i + for ; i < len(g.positions); i++ { + if g.positions[i].runes == runeIdx { + return g.positions[i], i + } + } + return closest, closestI +} + +func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos { + if len(g.positions) == 0 { + return combinedPos{} + } + i := sort.Search(len(g.positions), func(i int) bool { + pos := g.positions[i] + return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col) + }) + if i > 0 { + i-- + } + prior := g.positions[i] + if i+1 >= len(g.positions) { + return prior + } + next := g.positions[i+1] + if next.lineCol != lineCol { + return prior + } + return next +} + +func dist(a, b fixed.Int26_6) fixed.Int26_6 { + if a > b { + return a - b + } + return b - a +} + +func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos { + if len(g.positions) == 0 { + return combinedPos{} + } + i := sort.Search(len(g.positions), func(i int) bool { + pos := g.positions[i] + return pos.y+pos.descent.Round() >= y + }) + // If no position was greater than the provided Y, the text is too + // short. Return either the last position or (if there are no + // positions) the zero position. + if i == len(g.positions) { + return g.positions[i-1] + } + first := g.positions[i] + // Find the best X coordinate. + closest := i + closestDist := dist(first.x, x) + line := first.lineCol.line + // NOTE(whereswaldon): there isn't a simple way to accelerate this. Bidi text means that the x coordinates + // for positions have no fixed relationship. In the future, we can consider sorting the positions + // on a line by their x coordinate and caching that. It'll be a one-time O(nlogn) per line, but + // subsequent uses of this function for that line become O(logn). Right now it's always O(n). + for i := i + 1; i < len(g.positions) && g.positions[i].lineCol.line == line; i++ { + candidate := g.positions[i] + distance := dist(candidate.x, x) + // If we are *really* close to the current position candidate, just choose it. + if distance.Round() == 0 { + return g.positions[i] + } + if distance < closestDist { + closestDist = distance + closest = i + } + } + return g.positions[closest] +} + +// makeRegion creates a text-aligned rectangle from start to end. The vertical +// dimensions of the rectangle are derived from the provided line's ascent and +// descent, and the y offset of the line's baseline is provided as y. +func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) region { + if start > end { + start, end = end, start + } + dotStart := image.Pt(start.Round(), y) + dotEnd := image.Pt(end.Round(), y) + return region{ + bounds: image.Rectangle{ + Min: dotStart.Sub(image.Point{Y: line.ascent.Ceil()}), + Max: dotEnd.Add(image.Point{Y: line.descent.Floor()}), + }, + baseline: line.descent.Floor(), + } +} + +// region describes an area of interest within shaped text. +type region struct { + // bounds is the coordinates of the bounding box in document space. + bounds image.Rectangle + // baseline is the quantity of vertical pixels between the baseline and + // the bottom of bounds. + baseline int +} + +// locate returns highlight regions covering the glyphs that represent the runes in +// [startRune,endRune). If the rects parameter is non-nil, locate will use it to +// return results instead of allocating, provided that there is enough capacity. +func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []region) []region { + if startRune > endRune { + startRune, endRune = endRune, startRune + } + rects = rects[:0] + caretStart, _ := g.closestToRune(startRune) + caretEnd, _ := g.closestToRune(endRune) + + for lineIdx := caretStart.lineCol.line; lineIdx < len(g.lines); lineIdx++ { + if lineIdx > caretEnd.lineCol.line { + break + } + pos := g.closestToLineCol(screenPos{line: lineIdx}) + if int(pos.y)+pos.descent.Ceil() < viewport.Min.Y { + continue + } + if int(pos.y)-pos.ascent.Ceil() > viewport.Max.Y { + break + } + line := g.lines[lineIdx] + if lineIdx > caretStart.lineCol.line && lineIdx < caretEnd.lineCol.line { + startX := line.xOff + endX := startX + line.width + // The entire line is selected. + rects = append(rects, makeRegion(line, pos.y, startX, endX)) + continue + } + selectionStart := caretStart + selectionEnd := caretEnd + if lineIdx != caretStart.lineCol.line { + // This line does not contain the beginning of the selection. + selectionStart = g.closestToLineCol(screenPos{line: lineIdx}) + } + if lineIdx != caretEnd.lineCol.line { + // This line does not contain the end of the selection. + selectionEnd = g.closestToLineCol(screenPos{line: lineIdx, col: math.MaxInt}) + } + + var ( + startX, endX fixed.Int26_6 + eof bool + ) + lineLoop: + for !eof { + startX = selectionStart.x + if selectionStart.runIndex == selectionEnd.runIndex { + // Commit selection. + endX = selectionEnd.x + rects = append(rects, makeRegion(line, pos.y, startX, endX)) + break + } else { + currentDirection := selectionStart.towardOrigin + previous := selectionStart + runLoop: + for !eof { + // Increment the start position until the next logical run. + for startRun := selectionStart.runIndex; selectionStart.runIndex == startRun; { + previous = selectionStart + selectionStart, eof = g.incrementPosition(selectionStart) + if eof { + endX = selectionStart.x + rects = append(rects, makeRegion(line, pos.y, startX, endX)) + break runLoop + } + } + if selectionStart.towardOrigin != currentDirection { + endX = previous.x + rects = append(rects, makeRegion(line, pos.y, startX, endX)) + break + } + if selectionStart.runIndex == selectionEnd.runIndex { + // Commit selection. + endX = selectionEnd.x + rects = append(rects, makeRegion(line, pos.y, startX, endX)) + break lineLoop + } + } + } + } + } + return rects +} diff --git a/widget/index_test.go b/widget/index_test.go new file mode 100644 index 00000000..9d65032d --- /dev/null +++ b/widget/index_test.go @@ -0,0 +1,424 @@ +package widget + +import ( + "testing" + + nsareg "eliasnaur.com/font/noto/sans/arabic/regular" + "gioui.org/font/opentype" + "gioui.org/text" + "golang.org/x/image/font/gofont/goregular" + "golang.org/x/image/math/fixed" +) + +// makePosTestText returns two bidi samples of shaped text at the given +// font size and wrapped to the given line width. The runeLimit, if nonzero, +// truncates the sample text to ensure shorter output for expensive tests. +func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (bidiLTR, bidiRTL []text.Glyph) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + + shaper := text.NewShaper([]text.FontFace{ + { + Font: text.Font{Typeface: "LTR"}, + Face: ltrFace, + }, + { + Font: text.Font{Typeface: "RTL"}, + Face: rtlFace, + }, + }) + // bidiSource is crafted to contain multiple consecutive RTL runs (by + // changing scripts within the RTL). + bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog." + ltrParams := text.Parameters{Font: text.Font{Typeface: "LTR"}, PxPerEm: fixed.I(fontSize)} + rtlParams := text.Parameters{Alignment: text.End, Font: text.Font{Typeface: "RTL"}, PxPerEm: fixed.I(fontSize)} + if alignOpposite { + ltrParams.Alignment = text.End + rtlParams.Alignment = text.Start + } + shaper.LayoutString(ltrParams, 0, lineWidth, english, bidiSource) + for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { + bidiLTR = append(bidiLTR, g) + } + shaper.LayoutString(rtlParams, 0, lineWidth, arabic, bidiSource) + for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { + bidiRTL = append(bidiRTL, g) + } + return bidiLTR, bidiRTL +} + +// makeAccountingTestText shapes text designed to stress rune accounting +// logic within the index. +func makeAccountingTestText(fontSize, lineWidth int) (txt []text.Glyph) { + ltrFace, _ := opentype.Parse(goregular.TTF) + rtlFace, _ := opentype.Parse(nsareg.TTF) + + shaper := text.NewShaper([]text.FontFace{{ + Font: text.Font{Typeface: "LTR"}, + Face: ltrFace, + }, + { + Font: text.Font{Typeface: "RTL"}, + Face: rtlFace, + }, + }) + // bidiSource is crafted to contain multiple consecutive RTL runs (by + // changing scripts within the RTL). + bidiSource := "The\nquick سماء של\nום لا fox\nتمط של\nום." + params := text.Parameters{PxPerEm: fixed.I(fontSize)} + shaper.LayoutString(params, 0, lineWidth, english, bidiSource) + for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() { + txt = append(txt, g) + } + return txt +} + +// TestIndexPositionBidi tests whether the index correct generates cursor positions for +// complex bidirectional text. +func TestIndexPositionBidi(t *testing.T) { + fontSize := 16 + lineWidth := fontSize * 10 + bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) + type testcase struct { + name string + glyphs []text.Glyph + expectedXs []fixed.Int26_6 + } + for _, tc := range []testcase{ + { + name: "bidi ltr", + glyphs: bidiLTRText, + expectedXs: []fixed.Int26_6{ + 0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, 4753, // Positions on line 0. + 3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, 5890, // Positions on line 1. + 4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, 8813, // Positions on line 2. + 0, 570, 1140, 1710, 2034, // Positions on line 3. + }, + }, + { + name: "bidi rtl", + glyphs: bidiRTLText, + expectedXs: []fixed.Int26_6{ + 5368, 5994, 6564, 7134, 7419, 7989, 8559, 8812, 9324, 9836, 5368, 5102, 4299, 3707, 3409, 2988, 2722, 2108, 1494, 880, 266, 0, // Positions on line 0. + 8801, 8503, 8205, 7939, 6572, 6857, 7427, 7939, 6572, 6306, 6000, 5408, 4557, 4291, 3677, 3063, 2449, 1835, 1569, 1054, 672, 266, 0, // Positions on line 1. + 274, 564, 1134, 1704, 1989, 2263, 2833, 3345, 3857, 4142, 4712, 5282, 5852, 274, 0, // Positions on line 2. + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var gi glyphIndex + for _, g := range tc.glyphs { + gi.Glyph(g) + } + if len(gi.positions) != len(tc.expectedXs) { + t.Errorf("expected %d positions, got %d", len(tc.expectedXs), len(gi.positions)) + } + lastRunes := 0 + lastLine := 0 + lastCol := -1 + lastY := 0 + for i := 0; i < min(len(gi.positions), len(tc.expectedXs)); i++ { + actualX := gi.positions[i].x + expectedX := tc.expectedXs[i] + if actualX != expectedX { + t.Errorf("position %d: expected x=%v(%d), got x=%v(%d)", i, expectedX, expectedX, actualX, actualX) + } + if r := gi.positions[i].runes; r < lastRunes { + t.Errorf("position %d: expected runes >= %d, got %d", i, lastRunes, r) + } + lastRunes = gi.positions[i].runes + if y := gi.positions[i].y; y < lastY { + t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) + } + lastY = gi.positions[i].y + if y := gi.positions[i].y; y < lastY { + t.Errorf("position %d: expected y>= %d, got %d", i, lastY, y) + } + lastY = gi.positions[i].y + if lineCol := gi.positions[i].lineCol; lineCol.line == lastLine && lineCol.col < lastCol { + t.Errorf("position %d: expected col >= %d, got %d", i, lastCol, lineCol.col) + } + lastCol = gi.positions[i].lineCol.col + if line := gi.positions[i].lineCol.line; line < lastLine { + t.Errorf("position %d: expected line >= %d, got %d", i, lastLine, line) + } + lastLine = gi.positions[i].lineCol.line + } + printPositions(t, gi.positions) + if t.Failed() { + printGlyphs(t, tc.glyphs) + } + }) + } +} +func TestIndexPositionLines(t *testing.T) { + fontSize := 16 + lineWidth := fontSize * 10 + bidiLTRText, bidiRTLText := makePosTestText(fontSize, lineWidth, false) + bidiLTRTextOpp, bidiRTLTextOpp := makePosTestText(fontSize, lineWidth, true) + type testcase struct { + name string + glyphs []text.Glyph + expectedLines []lineInfo + } + for _, tc := range []testcase{ + { + name: "bidi ltr", + glyphs: bidiLTRText, + expectedLines: []lineInfo{ + { + xOff: fixed.Int26_6(0), + yOff: 22, + glyphs: 15, + width: fixed.Int26_6(7133), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(0), + yOff: 56, + glyphs: 15, + width: fixed.Int26_6(7905), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(0), + yOff: 90, + glyphs: 18, + width: fixed.Int26_6(8813), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(0), + yOff: 117, + glyphs: 4, + width: fixed.Int26_6(2034), + ascent: fixed.Int26_6(968), + descent: fixed.Int26_6(216), + }, + }, + }, + { + name: "bidi rtl", + glyphs: bidiRTLText, + expectedLines: []lineInfo{ + { + xOff: fixed.Int26_6(0), + yOff: 22, + glyphs: 20, + width: fixed.Int26_6(9836), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(0), + yOff: 56, + glyphs: 19, + width: fixed.Int26_6(8801), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(0), + yOff: 90, + glyphs: 13, + width: fixed.Int26_6(5852), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + }, + }, + { + name: "bidi ltr opposite alignment", + glyphs: bidiLTRTextOpp, + expectedLines: []lineInfo{ + { + xOff: fixed.Int26_6(3072), + yOff: 22, + glyphs: 15, + width: fixed.Int26_6(7133), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(2304), + yOff: 56, + glyphs: 15, + width: fixed.Int26_6(7905), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(1408), + yOff: 90, + glyphs: 18, + width: fixed.Int26_6(8813), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(8192), + yOff: 117, + glyphs: 4, + width: fixed.Int26_6(2034), + ascent: fixed.Int26_6(968), + descent: fixed.Int26_6(216), + }, + }, + }, + { + name: "bidi rtl opposite alignment", + glyphs: bidiRTLTextOpp, + expectedLines: []lineInfo{ + { + xOff: fixed.Int26_6(384), + yOff: 22, + glyphs: 20, + width: fixed.Int26_6(9836), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(1408), + yOff: 56, + glyphs: 19, + width: fixed.Int26_6(8801), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + { + xOff: fixed.Int26_6(4352), + yOff: 90, + glyphs: 13, + width: fixed.Int26_6(5852), + ascent: fixed.Int26_6(1407), + descent: fixed.Int26_6(756), + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var gi glyphIndex + for _, g := range tc.glyphs { + gi.Glyph(g) + } + if len(gi.lines) != len(tc.expectedLines) { + t.Errorf("expected %d lines, got %d", len(tc.expectedLines), len(gi.lines)) + } + for i := 0; i < min(len(gi.lines), len(tc.expectedLines)); i++ { + actual := gi.lines[i] + expected := tc.expectedLines[i] + if actual != expected { + t.Errorf("line %d: expected:\n%#+v, got:\n%#+v", i, expected, actual) + } + } + }) + } +} + +// TestIndexPositionRunes checks for rune accounting errors in positions +// generated by the index. +func TestIndexPositionRunes(t *testing.T) { + fontSize := 16 + lineWidth := fontSize * 10 + testText := makeAccountingTestText(fontSize, lineWidth) + type testcase struct { + name string + glyphs []text.Glyph + expected []combinedPos + } + for _, tc := range []testcase{ + { + name: "many newlines", + glyphs: testText, + expected: []combinedPos{ + {runes: 0, lineCol: screenPos{line: 0, col: 0}, runIndex: 0, towardOrigin: false}, + {runes: 1, lineCol: screenPos{line: 0, col: 1}, runIndex: 0, towardOrigin: false}, + {runes: 2, lineCol: screenPos{line: 0, col: 2}, runIndex: 0, towardOrigin: false}, + {runes: 3, lineCol: screenPos{line: 0, col: 3}, runIndex: 0, towardOrigin: false}, + {runes: 4, lineCol: screenPos{line: 1, col: 0}, runIndex: 0, towardOrigin: false}, + {runes: 5, lineCol: screenPos{line: 1, col: 1}, runIndex: 0, towardOrigin: false}, + {runes: 6, lineCol: screenPos{line: 1, col: 2}, runIndex: 0, towardOrigin: false}, + {runes: 7, lineCol: screenPos{line: 1, col: 3}, runIndex: 0, towardOrigin: false}, + {runes: 8, lineCol: screenPos{line: 1, col: 4}, runIndex: 0, towardOrigin: false}, + {runes: 9, lineCol: screenPos{line: 1, col: 5}, runIndex: 0, towardOrigin: false}, + {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 0, towardOrigin: false}, + {runes: 10, lineCol: screenPos{line: 1, col: 6}, runIndex: 1, towardOrigin: true}, + {runes: 11, lineCol: screenPos{line: 1, col: 7}, runIndex: 1, towardOrigin: true}, + {runes: 12, lineCol: screenPos{line: 1, col: 8}, runIndex: 1, towardOrigin: true}, + {runes: 13, lineCol: screenPos{line: 1, col: 9}, runIndex: 1, towardOrigin: true}, + {runes: 14, lineCol: screenPos{line: 1, col: 10}, runIndex: 1, towardOrigin: true}, + {runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 1, towardOrigin: true}, + {runes: 16, lineCol: screenPos{line: 1, col: 12}, runIndex: 2, towardOrigin: true}, + {runes: 17, lineCol: screenPos{line: 1, col: 13}, runIndex: 2, towardOrigin: true}, + {runes: 18, lineCol: screenPos{line: 2, col: 0}, runIndex: 0, towardOrigin: true}, + {runes: 19, lineCol: screenPos{line: 2, col: 1}, runIndex: 0, towardOrigin: true}, + {runes: 20, lineCol: screenPos{line: 2, col: 2}, runIndex: 0, towardOrigin: true}, + {runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 0, towardOrigin: true}, + {runes: 22, lineCol: screenPos{line: 2, col: 4}, runIndex: 1, towardOrigin: true}, + {runes: 23, lineCol: screenPos{line: 2, col: 5}, runIndex: 1, towardOrigin: true}, + {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 1, towardOrigin: true}, + {runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 2, towardOrigin: false}, + {runes: 25, lineCol: screenPos{line: 2, col: 7}, runIndex: 2, towardOrigin: false}, + {runes: 26, lineCol: screenPos{line: 2, col: 8}, runIndex: 2, towardOrigin: false}, + {runes: 27, lineCol: screenPos{line: 2, col: 9}, runIndex: 2, towardOrigin: false}, + {runes: 28, lineCol: screenPos{line: 3, col: 0}, runIndex: 0, towardOrigin: true}, + {runes: 29, lineCol: screenPos{line: 3, col: 1}, runIndex: 0, towardOrigin: true}, + {runes: 30, lineCol: screenPos{line: 3, col: 2}, runIndex: 0, towardOrigin: true}, + {runes: 31, lineCol: screenPos{line: 3, col: 3}, runIndex: 0, towardOrigin: true}, + {runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 0, towardOrigin: true}, + {runes: 33, lineCol: screenPos{line: 3, col: 5}, runIndex: 1, towardOrigin: true}, + {runes: 34, lineCol: screenPos{line: 3, col: 6}, runIndex: 1, towardOrigin: true}, + {runes: 35, lineCol: screenPos{line: 4, col: 0}, runIndex: 0, towardOrigin: true}, + {runes: 36, lineCol: screenPos{line: 4, col: 1}, runIndex: 0, towardOrigin: true}, + {runes: 37, lineCol: screenPos{line: 4, col: 2}, runIndex: 0, towardOrigin: true}, + {runes: 38, lineCol: screenPos{line: 4, col: 3}, runIndex: 0, towardOrigin: true}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var gi glyphIndex + for _, g := range tc.glyphs { + gi.Glyph(g) + } + if len(gi.positions) != len(tc.expected) { + t.Errorf("expected %d positions, got %d", len(tc.expected), len(gi.positions)) + } + for i := 0; i < min(len(gi.positions), len(tc.expected)); i++ { + actual := gi.positions[i] + expected := tc.expected[i] + if expected.runes != actual.runes { + t.Errorf("position %d: expected runes=%d, got %d", i, expected.runes, actual.runes) + } + if expected.lineCol != actual.lineCol { + t.Errorf("position %d: expected lineCol=%v, got %v", i, expected.lineCol, actual.lineCol) + } + if expected.runIndex != actual.runIndex { + t.Errorf("position %d: expected runIndex=%d, got %d", i, expected.runIndex, actual.runIndex) + } + if expected.towardOrigin != actual.towardOrigin { + t.Errorf("position %d: expected towardOrigin=%v, got %v", i, expected.towardOrigin, actual.towardOrigin) + } + } + printPositions(t, gi.positions) + if t.Failed() { + printGlyphs(t, tc.glyphs) + } + }) + } +} +func printPositions(t *testing.T, positions []combinedPos) { + t.Helper() + for i, p := range positions { + t.Logf("positions[%2d] = {runes: %2d, line: %2d, col: %2d, x: %5d, y: %3d}", i, p.runes, p.lineCol.line, p.lineCol.col, p.x, p.y) + } +} + +func printGlyphs(t *testing.T, glyphs []text.Glyph) { + t.Helper() + for i, g := range glyphs { + t.Logf("glyphs[%2d] = {ID: 0x%013x, Flags: %4s, Advance: %4d(%6v), Runes: %d, Y: %3d, X: %4d(%6v)} ", i, g.ID, g.Flags, g.Advance, g.Advance, g.Runes, g.Y, g.X, g.X) + } +} diff --git a/widget/label.go b/widget/label.go index c1a8d53e..9799dfc1 100644 --- a/widget/label.go +++ b/widget/label.go @@ -3,11 +3,9 @@ package widget import ( - "fmt" "image" "gioui.org/io/semantic" - "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" @@ -26,190 +24,100 @@ type Label struct { MaxLines int } -// screenPos describes a character position (in text line and column numbers, -// not pixels): Y = line number, X = rune column. -type screenPos image.Point - -const inf = 1e6 - -// posIsAbove returns whether the position described in pos by the lineCol and -// y fields is above the given y coordinate. It is invalid to call this function -// unless both the lineCol and (x,y) fields of pos are populated. -func posIsAbove(lines []text.Line, pos combinedPos, y int) bool { - line := lines[pos.lineCol.Y] - return pos.y+line.Bounds.Max.Y.Ceil() < y -} - -// posIsAbove returns whether the position described in pos by the lineCol and -// y fields is below the given y coordinate. It is invalid to call this function -// unless both the lineCol and (x,y) fields of pos are populated. -func posIsBelow(lines []text.Line, pos combinedPos, y int) bool { - line := lines[pos.lineCol.Y] - return pos.y+line.Bounds.Min.Y.Floor() > y -} - -func clipLine(lines []text.Line, alignment text.Alignment, width int, clip image.Rectangle, linePos combinedPos) (start combinedPos, end combinedPos) { - // Seek to first (potentially) visible column. - lineIdx := linePos.lineCol.Y - line := lines[lineIdx] - // runeWidth is the width of the widest rune in line. - runeWidth := (line.Bounds.Max.X - line.Width).Ceil() - lineStart := fixed.I(clip.Min.X - runeWidth) - lineEnd := fixed.I(clip.Max.X + runeWidth) - - flip := line.Layout.Direction.Progression() == system.TowardOrigin - if flip { - lineStart, lineEnd = lineEnd, lineStart - } - q := combinedPos{y: start.y, x: lineStart} - start, _ = seekPosition(lines, alignment, width, linePos, q, 0) - // Seek to first invisible column after start. - q = combinedPos{y: start.y, x: lineEnd} - end, _ = seekPosition(lines, alignment, width, start, q, 0) - if flip { - start, end = end, start - } - - return start, end -} - -func subLayout(line text.Line, start, end combinedPos) text.Layout { - startCluster := clusterIndexFor(line, start.lineCol.X, start.clusterIndex) - endCluster := clusterIndexFor(line, end.lineCol.X, end.clusterIndex) - if line.Layout.Direction.Progression() == system.TowardOrigin { - startCluster, endCluster = endCluster, startCluster - } - return line.Layout.Slice(startCluster, endCluster) -} - -// firstPos returns a combinedPos with *only* the x and y -// fields populated. They will be set to the location of the -// dot at the beginning of the line, with text alignment taken -// into account. For RTL text, this will -// be on the right edge of the available space. -// -// The results can be counterinuitive due to the fact that meaning -// of alignment changes depending on the text direction. -// -// The returned pos can be considered valid only for the first line -// of a body of text. -func firstPos(line text.Line, alignment text.Alignment, width int) combinedPos { - p := combinedPos{ - x: align(alignment, line.Layout.Direction, line.Width, width), - y: line.Ascent.Ceil(), - } - - if line.Layout.Direction.Progression() == system.TowardOrigin { - p.x += line.Width - } - return p -} - -func (l Label) Layout(gtx layout.Context, s text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions { +func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font text.Font, size unit.Sp, txt string) layout.Dimensions { cs := gtx.Constraints textSize := fixed.I(gtx.Sp(size)) - lines := s.LayoutString(font, textSize, cs.Max.X, gtx.Locale, txt) - if max := l.MaxLines; max > 0 && len(lines) > max { - lines = lines[:max] - } - dims := linesDimens(lines) - dims.Size = cs.Constrain(dims.Size) - if len(lines) == 0 { - return dims - } - cl := textPadding(lines) - cl.Max = cl.Max.Add(dims.Size) - defer clip.Rect(cl).Push(gtx.Ops).Pop() + lt.LayoutString(text.Parameters{ + Font: font, + PxPerEm: textSize, + MaxLines: l.MaxLines, + Alignment: l.Alignment, + }, cs.Min.X, cs.Max.X, gtx.Locale, txt) + m := op.Record(gtx.Ops) + viewport := image.Rectangle{Max: cs.Max} + it := textIterator{viewport: viewport} semantic.LabelOp(txt).Add(gtx.Ops) - pos := firstPos(lines[0], l.Alignment, dims.Size.X) - for !posIsBelow(lines, pos, cl.Max.Y) { - start, end := clipLine(lines, l.Alignment, dims.Size.X, cl, pos) - line := lines[start.lineCol.Y] - lt := subLayout(line, start, end) - - off := image.Point{X: start.x.Floor(), Y: start.y} - t := op.Offset(off).Push(gtx.Ops) - op := clip.Outline{Path: s.Shape(font, textSize, lt)}.Op().Push(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - op.Pop() - t.Pop() - - if pos.lineCol.Y == len(lines)-1 { - break + var gs [32]text.Glyph + line := gs[:0] + var lineOff image.Point + for it.Glyph(lt.NextGlyph()) { + if it.visible { + if len(line) == 0 { + lineOff = image.Point{X: it.g.X.Floor(), Y: int(it.g.Y)} + } + line = append(line, it.g) + } + if it.g.Flags&text.FlagLineBreak > 0 || cap(line)-len(line) == 0 { + t := op.Offset(lineOff).Push(gtx.Ops) + op := clip.Outline{Path: lt.Shape(line)}.Op().Push(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + op.Pop() + t.Pop() + line = line[:0] } - pos, _ = seekPosition(lines, l.Alignment, dims.Size.X, pos, combinedPos{lineCol: screenPos{Y: pos.lineCol.Y + 1}}, 0) } + call := m.Stop() + viewport.Min = viewport.Min.Add(it.padding.Min) + viewport.Max = viewport.Max.Add(it.padding.Max) + clipStack := clip.Rect(viewport).Push(gtx.Ops) + call.Add(gtx.Ops) + dims := layout.Dimensions{Size: it.bounds.Size()} + dims.Size = cs.Constrain(dims.Size) + dims.Baseline = dims.Size.Y - it.baseline + clipStack.Pop() return dims } -func textPadding(lines []text.Line) (padding image.Rectangle) { - if len(lines) == 0 { - return - } - first := lines[0] - if d := first.Ascent + first.Bounds.Min.Y; d < 0 { - padding.Min.Y = d.Ceil() - } - last := lines[len(lines)-1] - if d := last.Bounds.Max.Y - last.Descent; d > 0 { - padding.Max.Y = d.Ceil() - } - if d := first.Bounds.Min.X; d < 0 { - padding.Min.X = d.Ceil() - } - if d := first.Bounds.Max.X - first.Width; d > 0 { - padding.Max.X = d.Ceil() - } - return +func r2p(r clip.Rect) clip.Op { + return clip.Stroke{Path: r.Path(), Width: 1}.Op() } -func linesDimens(lines []text.Line) layout.Dimensions { - var width fixed.Int26_6 - var h int - var baseline int - if len(lines) > 0 { - baseline = lines[0].Ascent.Ceil() - var prevDesc fixed.Int26_6 - for _, l := range lines { - h += (prevDesc + l.Ascent).Ceil() - prevDesc = l.Descent - if l.Width > width { - width = l.Width - } - } - h += lines[len(lines)-1].Descent.Ceil() - } - w := width.Ceil() - return layout.Dimensions{ - Size: image.Point{ - X: w, - Y: h, - }, - Baseline: h - baseline, - } +type textIterator struct { + g text.Glyph + viewport image.Rectangle + padding image.Rectangle + bounds image.Rectangle + visible bool + first bool + baseline int } -// align returns the x offset that should be applied to text with width so that it -// appears correctly aligned within a space of size maxWidth and with the primary -// text direction dir. -func align(align text.Alignment, dir system.TextDirection, width fixed.Int26_6, maxWidth int) fixed.Int26_6 { - mw := fixed.I(maxWidth) - if dir.Progression() == system.TowardOrigin { - switch align { - case text.Start: - align = text.End - case text.End: - align = text.Start - } +func (t *textIterator) Glyph(g text.Glyph, ok bool) bool { + t.g = g + bounds := image.Rectangle{ + Min: image.Pt(g.Bounds.Min.X.Floor(), g.Bounds.Min.Y.Floor()), + Max: image.Pt(g.Bounds.Max.X.Ceil(), g.Bounds.Max.Y.Ceil()), } - switch align { - case text.Middle: - return fixed.I(((mw - width) / 2).Floor()) - case text.End: - return fixed.I((mw - width).Floor()) - case text.Start: - return 0 - default: - panic(fmt.Errorf("unknown alignment %v", align)) + // Compute the maximum extent to which glyphs overhang on the horizontal + // axis. + if d := g.Bounds.Min.X.Floor(); d < t.padding.Min.X { + t.padding.Min.X = d } + if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > t.padding.Max.X { + t.padding.Max.X = d + } + // Convert the bounds from dot-relative coordinates to document coordinates. + bounds = bounds.Add(image.Pt(g.X.Round(), int(g.Y))) + if !t.first { + t.first = true + t.baseline = int(g.Y) + t.bounds = bounds + } + + above := bounds.Max.Y < t.viewport.Min.Y + below := bounds.Min.Y > t.viewport.Max.Y + left := bounds.Max.X < t.viewport.Min.X + right := bounds.Min.X > t.viewport.Max.X + t.visible = !above && !below && !left && !right + if t.visible { + t.bounds.Min.X = min(t.bounds.Min.X, bounds.Min.X) + t.bounds.Min.Y = min(t.bounds.Min.Y, int(g.Y)-g.Ascent.Ceil()) + t.bounds.Max.X = max(t.bounds.Max.X, bounds.Max.X) + t.bounds.Max.Y = max(t.bounds.Max.Y, int(g.Y)+g.Descent.Ceil()) + } + if t.bounds.Dy() == 0 { + t.bounds.Min.Y = -g.Ascent.Ceil() + t.bounds.Max.Y = g.Descent.Ceil() + } + return ok && !below } diff --git a/widget/material/button.go b/widget/material/button.go index bf71be9b..290b5476 100644 --- a/widget/material/button.go +++ b/widget/material/button.go @@ -28,7 +28,7 @@ type ButtonStyle struct { CornerRadius unit.Dp Inset layout.Inset Button *widget.Clickable - shaper text.Shaper + shaper *text.Shaper } type ButtonLayoutStyle struct { diff --git a/widget/material/checkable.go b/widget/material/checkable.go index 6a370e3f..8312d99c 100644 --- a/widget/material/checkable.go +++ b/widget/material/checkable.go @@ -22,7 +22,7 @@ type checkable struct { TextSize unit.Sp IconColor color.NRGBA Size unit.Dp - shaper text.Shaper + shaper *text.Shaper checkedStateIcon *widget.Icon uncheckedStateIcon *widget.Icon } diff --git a/widget/material/editor.go b/widget/material/editor.go index d650ae2f..9b25cf9f 100644 --- a/widget/material/editor.go +++ b/widget/material/editor.go @@ -28,7 +28,7 @@ type EditorStyle struct { SelectionColor color.NRGBA Editor *widget.Editor - shaper text.Shaper + shaper *text.Shaper } func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle { diff --git a/widget/material/label.go b/widget/material/label.go index 587388fd..ad41db3a 100644 --- a/widget/material/label.go +++ b/widget/material/label.go @@ -24,7 +24,7 @@ type LabelStyle struct { Text string TextSize unit.Sp - shaper text.Shaper + shaper *text.Shaper } func H1(th *Theme, txt string) LabelStyle { diff --git a/widget/material/theme.go b/widget/material/theme.go index 65135275..350d995d 100644 --- a/widget/material/theme.go +++ b/widget/material/theme.go @@ -32,7 +32,7 @@ type Palette struct { } type Theme struct { - Shaper text.Shaper + Shaper *text.Shaper Palette TextSize unit.Sp Icon struct { @@ -48,7 +48,7 @@ type Theme struct { func NewTheme(fontCollection []text.FontFace) *Theme { t := &Theme{ - Shaper: text.NewCache(fontCollection), + Shaper: text.NewShaper(fontCollection), } t.Palette = Palette{ Fg: rgb(0x000000), diff --git a/widget/testdata/fuzz/FuzzEditorEditing/18c534da60e6b61361786a120fe7b82978ac0e5c16afb519beb080f73c470d3f b/widget/testdata/fuzz/FuzzEditorEditing/18c534da60e6b61361786a120fe7b82978ac0e5c16afb519beb080f73c470d3f new file mode 100644 index 00000000..2763792f --- /dev/null +++ b/widget/testdata/fuzz/FuzzEditorEditing/18c534da60e6b61361786a120fe7b82978ac0e5c16afb519beb080f73c470d3f @@ -0,0 +1,4 @@ +go test fuzz v1 +string("\xa3\xd90") +int16(-11) +int16(2237) diff --git a/widget/testdata/fuzz/FuzzEditorEditing/a489f2d9f9226d13b55846645d025ac15316f1210d4aa307cde333cc87fdad55 b/widget/testdata/fuzz/FuzzEditorEditing/a489f2d9f9226d13b55846645d025ac15316f1210d4aa307cde333cc87fdad55 new file mode 100644 index 00000000..b0d68f14 --- /dev/null +++ b/widget/testdata/fuzz/FuzzEditorEditing/a489f2d9f9226d13b55846645d025ac15316f1210d4aa307cde333cc87fdad55 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("") +int16(44) +int16(2299) diff --git a/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash1 b/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash1 new file mode 100644 index 00000000..8511f5f4 --- /dev/null +++ b/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash1 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("و سأعر مثpل dolor sitamet, لم يتحم ج د adipscin eit, seddo الحصول علىpمhزة nرidiإun utNlaboe أ يتد magna aliqua.\nPorotit.r رادتهم فيتسوي rbi non aنcuيدكن ما يعقبها .\nNibhiنشجب ورستنكر ommodo nulla\nبكل سهولة ورونة ut consquat لهذا، من من .nam liber jظsto.يRisعs n hمndrerit لينا اضواجبالعمل.نNatoqe تكون Lدتا علي magis disparturient يخسك زمم الأمور ويخرار.\nIn جد ما يsنعنeu أcelerisquecونرا للاتزامات التي ةer entum.إMattis شرط وعندما لا neلue viverra.\nيمسكrزام الأمور abaan لهذر، .\nNisl تي يفض ا علينانfaucًbus ،من منا rm nec.\nSedكauue rلي الاختيار غر vitacongue eu nsequat.\nAt quis risus ك زمام الأمخر ومتا.tSit amet volutpat conseqt maسrisالأمو يiتارثإما nisi.\nDiguissim واجبوالعdل tincidunt نتنمزل feugiatn\nFauلibus التزاماتin eu m bإbendum.رOdio وcخرار إما أن ير صاسر السعادة ned adpiscig ذا، منeنا لم tistiue.\nFeretum leo vel ور ويختور ما pulvi\nr\nUtرإما أ يرفض صادر الmعادة من in metus تlون قدرتوايعليقgىelis imeodلet.\nي التتgر غي مقيدةمبشر et mنمsuada iamمs acنturpis.\nVennatis عل ميزة أو فائدة؟ ولكن oege nuc scele pque سزام ولأمو ويختار ما in.\nرتنا ultricieوtristique ي لااتيار غير قيدة بشرط enim trtor.\nRisus اختيiر غير قيدةeشط عندما quam سان الحكيم عليه أن .suspedsse in.Interمm vاlit نظرا للالتزام التي pellentesue massa pعaceiat ل مرضويتار إما أن يرفض acus.nProin دا تكون قدرتنا علي الاختيار lec us a.\nAuqtor الوقم عيدا تون aegue neque مال q fermentm et.\nطمeet ماك زام الأمور uيختار يmet cursuم لم يتحملجهدو نictum.uIn\nfermetum et sollicitudin ac orci nhبsellus علo الاخت.ا غ rutrum\nTemus mperdietلالمفترض أن.نفرق pellentesque ت بك سهو ة eget raidaم\nرonsequat id prvaمصادا السادة cras ned.\nVlputat رعلي الاختيارغير قيدة sitاamet aliquam\nmongue mauis حيمن ونظراً للالتزاnات التي elelit.\nRgsus qeis vهrius اuam quisque id ار غير قدة بشرط elementumوPreti m تي يفرضهاعuينا الاجب io in vitee.\n شاق إلا مب أل retium qua احكيم عليه أنsيمسك suspendss in et.Vlit ونظراً للالتزامات التي يفرض ultrice .\n الوقت عندما تكون velit dinissim يه ن يمس .\nرnc sclerisque vverramauris inلaliquam sim ً إا أن ut.\nالسعادt ك أجل مl هو أكثر أهمية أو يتحل الألم\nConvillis pفsuere morbi leo una molestie at.") +int16(613) +int16(1976) diff --git a/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash2 b/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash2 new file mode 100644 index 00000000..0c07203c --- /dev/null +++ b/widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash2 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ\nد بوولنكcmme ul\nبsسة uولnوuoeaهnا،من mن tn libeo jtsteاisusهin hen عnنادmب eلعملNaلeسكنعقرنا علي ااs diلre rinti مسl زي.pلrور و \nار.تn ندمم نا e sc\nlلrsueونً Tالت mاL التمeزe.\nMaيit e و وكدمl مfeierta.\nمسmزامhالر قhi tnoلهاموoرNu تrFر ع ي اauipus ،i نالمneauemiاm علنتنارغيرvitas au c n eqnt.\nAtuسuخ sبمام grأور ختaغ.\n mee v utهateس e alاmis\nأمأاكيخاار إماsi.igmi واجب ا rتnتunt iنتنزiueلia.ضةaucb التتا n eu mi رمndكOd ايي. اإهنيرe مدرلخ اد sd ndiاun ذ من مالe triلu.iقmcnoui أo وaوياوإ.pulvinar.\nrت إماأ يرف مصSاfعدمنiو مts iن ورتنم eliاieيرt.\nي ا شغرحميمبط ettlsuda apeاsa turqe.Veneتtisiع ميةوأو sة؟ كن eرncaيliuatم امnر ايتارإga uemاutrci biqu ً.لiيار ي مlي لطen اtو or\nRssخياoغيراميe شmcصناو uaيويمعله دس su.endiRseini\nnrاie\nt نرللسنز يات لتي pelqe iact لأمو را تريض macsmPoاا ا نأSدتن امدrت م lntuا u.\nAuatr سيتiياtجون ut nequ ضيى eo seض etumثtجaoee مسl tم.مuأيت وmt crrsغ c sمs اdictn.I أ,entrm Doiiludin c iaseleusيعلالختيررر rutrmmيmp mprieh فeرةa ن dف\nesمeldمsue يdكل امrخd egt i a eu.uCs uaن drtaمشدر لعدةشrنeظ\nVlpuatrعي pختيارهmرcمقيoةit am t اleua.\n ongulmaقisين.نnرل لللتز لتي م.\niousqui varNs اeل uiue ld uريحميcن برeneeum\nPrtزm يفرضا عاالاجب ن sيeشدelممن جل prstiتiqu rا\nحكlم ل ها،نك uspe duse اn ًaنeliت ً رازv لتي رفfيىutلise lsق oدمتكونيvelidgsدc tvle sNe أscاplsqنeمvشساtuتsرin aliqطi e ر إمدأنةقut لع ن أجuلما ه vرمة tصتحماللم\nConveli posمeل er leرr mلee .") +int16(1228) +int16(782) diff --git a/widget/text_bench_test.go b/widget/text_bench_test.go index 68e41b85..03d834cd 100644 --- a/widget/text_bench_test.go +++ b/widget/text_bench_test.go @@ -4,15 +4,19 @@ import ( "fmt" "image" "math/rand" + "os" + "sort" "testing" "time" "gioui.org/font/gofont" + "gioui.org/gpu/headless" "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/text" "gioui.org/unit" + "golang.org/x/exp/maps" ) var ( @@ -33,142 +37,218 @@ func init() { rand.Seed(int64(time.Now().Nanosecond())) } -func BenchmarkLabelStatic(b *testing.B) { +func runBenchmarkPermutations(b *testing.B, benchmark func(b *testing.B, runes int, locale system.Locale, document string)) { + docKeys := maps.Keys(documents) + sort.Strings(docKeys) for _, locale := range locales { for _, runes := range sizes { - for textType, txt := range documents { + for _, textType := range docKeys { + txt := documents[textType] b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) { - gtx := layout.Context{ - Ops: new(op.Ops), - Constraints: layout.Constraints{ - Max: image.Pt(200, 1000), - }, - Locale: locale, - } - cache := text.NewCache(benchFonts) - fontSize := unit.Sp(10) - font := text.Font{} - runes := []rune(txt)[:runes] - runesStr := string(runes) - l := Label{} - b.ResetTimer() - for i := 0; i < b.N; i++ { - l.Layout(gtx, cache, font, fontSize, runesStr) - gtx.Ops.Reset() - } + benchmark(b, runes, locale, txt) }) } } } } +var render bool + +func init() { + if _, ok := os.LookupEnv("RENDER_WIDGET_TESTS"); ok { + render = true + } +} + +func BenchmarkLabelStatic(b *testing.B) { + runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) { + var win *headless.Window + size := image.Pt(200, 1000) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Constraints{ + Max: size, + }, + Locale: locale, + } + cache := text.NewShaper(benchFonts) + if render { + win, _ = headless.NewWindow(size.X, size.Y) + defer win.Release() + } + fontSize := unit.Sp(10) + font := text.Font{} + runes := []rune(txt)[:runeCount] + runesStr := string(runes) + l := Label{} + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Layout(gtx, cache, font, fontSize, runesStr) + if render { + win.Frame(gtx.Ops) + } + gtx.Ops.Reset() + } + }) +} + func BenchmarkLabelDynamic(b *testing.B) { - for _, locale := range locales { - for _, runes := range sizes { - for textType, txt := range documents { - b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) { - gtx := layout.Context{ - Ops: new(op.Ops), - Constraints: layout.Constraints{ - Max: image.Pt(200, 1000), - }, - Locale: locale, - } - cache := text.NewCache(benchFonts) - fontSize := unit.Sp(10) - font := text.Font{} - runes := []rune(txt)[:runes] - l := Label{} - b.ResetTimer() - for i := 0; i < b.N; i++ { - // simulate a constantly changing string - a := rand.Intn(len(runes)) - b := rand.Intn(len(runes)) - runes[a], runes[b] = runes[b], runes[a] - l.Layout(gtx, cache, font, fontSize, string(runes)) - gtx.Ops.Reset() - } - }) - } + runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) { + var win *headless.Window + size := image.Pt(200, 1000) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Constraints{ + Max: size, + }, + Locale: locale, } - } + cache := text.NewShaper(benchFonts) + if render { + win, _ = headless.NewWindow(size.X, size.Y) + defer win.Release() + } + fontSize := unit.Sp(10) + font := text.Font{} + runes := []rune(txt)[:runeCount] + l := Label{} + b.ResetTimer() + for i := 0; i < b.N; i++ { + // simulate a constantly changing string + a := rand.Intn(len(runes)) + b := rand.Intn(len(runes)) + runes[a], runes[b] = runes[b], runes[a] + l.Layout(gtx, cache, font, fontSize, string(runes)) + if render { + win.Frame(gtx.Ops) + } + gtx.Ops.Reset() + } + }) } func BenchmarkEditorStatic(b *testing.B) { - for _, locale := range locales { - for _, runes := range sizes { - for textType, txt := range documents { - b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) { - gtx := layout.Context{ - Ops: new(op.Ops), - Constraints: layout.Constraints{ - Max: image.Pt(200, 1000), - }, - Locale: locale, - } - cache := text.NewCache(benchFonts) - fontSize := unit.Sp(10) - font := text.Font{} - runes := []rune(txt)[:runes] - runesStr := string(runes) - e := Editor{} - e.SetText(runesStr) - b.ResetTimer() - for i := 0; i < b.N; i++ { - e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { - e.PaintSelection(gtx) - e.PaintText(gtx) - e.PaintCaret(gtx) - return layout.Dimensions{Size: gtx.Constraints.Min} - }) - gtx.Ops.Reset() - } - }) - } + runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) { + var win *headless.Window + size := image.Pt(200, 1000) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Constraints{ + Max: size, + }, + Locale: locale, } - } + cache := text.NewShaper(benchFonts) + if render { + win, _ = headless.NewWindow(size.X, size.Y) + defer win.Release() + } + fontSize := unit.Sp(10) + font := text.Font{} + runes := []rune(txt)[:runeCount] + runesStr := string(runes) + e := Editor{} + e.SetText(runesStr) + b.ResetTimer() + for i := 0; i < b.N; i++ { + e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { + e.PaintSelection(gtx) + e.PaintText(gtx) + e.PaintCaret(gtx) + return layout.Dimensions{Size: gtx.Constraints.Min} + }) + if render { + win.Frame(gtx.Ops) + } + gtx.Ops.Reset() + } + }) } func BenchmarkEditorDynamic(b *testing.B) { - for _, locale := range locales { - for _, runes := range sizes { - for textType, txt := range documents { - b.Run(fmt.Sprintf("%drunes-%s-%s", runes, locale.Direction, textType), func(b *testing.B) { - gtx := layout.Context{ - Ops: new(op.Ops), - Constraints: layout.Constraints{ - Max: image.Pt(200, 1000), - }, - Locale: locale, - } - cache := text.NewCache(benchFonts) - fontSize := unit.Sp(10) - font := text.Font{} - runes := []rune(txt)[:runes] - e := Editor{} - e.SetText(string(runes)) - b.ResetTimer() - for i := 0; i < b.N; i++ { - // simulate a constantly changing string - a := rand.Intn(e.Len()) - b := rand.Intn(e.Len()) - e.SetCaret(a, a+1) - takeStr := e.SelectedText() - e.Insert("") - e.SetCaret(b, b) - e.Insert(takeStr) - e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { - e.PaintSelection(gtx) - e.PaintText(gtx) - e.PaintCaret(gtx) - return layout.Dimensions{Size: gtx.Constraints.Min} - }) - gtx.Ops.Reset() - } - }) - } + runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) { + var win *headless.Window + size := image.Pt(200, 1000) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Constraints{ + Max: size, + }, + Locale: locale, } + cache := text.NewShaper(benchFonts) + if render { + win, _ = headless.NewWindow(size.X, size.Y) + defer win.Release() + } + fontSize := unit.Sp(10) + font := text.Font{} + runes := []rune(txt)[:runeCount] + e := Editor{} + e.SetText(string(runes)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // simulate a constantly changing string + a := rand.Intn(e.Len()) + b := rand.Intn(e.Len()) + e.SetCaret(a, a+1) + takeStr := e.SelectedText() + e.Insert("") + e.SetCaret(b, b) + e.Insert(takeStr) + e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { + e.PaintSelection(gtx) + e.PaintText(gtx) + e.PaintCaret(gtx) + return layout.Dimensions{Size: gtx.Constraints.Min} + }) + if render { + win.Frame(gtx.Ops) + } + gtx.Ops.Reset() + } + }) +} + +func FuzzEditorEditing(f *testing.F) { + f.Add(complexDocument, int16(0), int16(len([]rune(complexDocument)))) + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Constraints{ + Max: image.Pt(200, 1000), + }, + Locale: arabic, } + cache := text.NewShaper(benchFonts) + fontSize := unit.Sp(10) + font := text.Font{} + e := Editor{} + f.Fuzz(func(t *testing.T, txt string, replaceFrom, replaceTo int16) { + e.SetText(txt) + e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { + e.PaintSelection(gtx) + e.PaintText(gtx) + e.PaintCaret(gtx) + return layout.Dimensions{Size: gtx.Constraints.Min} + }) + // simulate a constantly changing string + if e.Len() > 0 { + a := int(replaceFrom) % e.Len() + b := int(replaceTo) % e.Len() + e.SetCaret(a, a+1) + takeStr := e.SelectedText() + e.Insert("") + e.SetCaret(b, b) + e.Insert(takeStr) + } + e.Layout(gtx, cache, font, fontSize, func(gtx layout.Context) layout.Dimensions { + e.PaintSelection(gtx) + e.PaintText(gtx) + e.PaintCaret(gtx) + return layout.Dimensions{Size: gtx.Constraints.Min} + }) + gtx.Ops.Reset() + }) } const ( diff --git a/widget/text_test.go b/widget/text_test.go deleted file mode 100644 index 88e64410..00000000 --- a/widget/text_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package widget - -import ( - "math" - "strconv" - "testing" - - nsareg "eliasnaur.com/font/noto/sans/arabic/regular" - "gioui.org/font/opentype" - "gioui.org/io/system" - "gioui.org/text" - "golang.org/x/image/font/gofont/goregular" - "golang.org/x/image/math/fixed" -) - -// makeTestText returns an ltr and rtl sample of shaped text at the given -// font size and wrapped to the given line width. The runeLimit, if nonzero, -// truncates the sample text to ensure shorter output for expensive tests. -func makeTestText(fontSize, lineWidth, runeLimit int) ([]text.Line, []text.Line) { - ltrFace, _ := opentype.Parse(goregular.TTF) - rtlFace, _ := opentype.Parse(nsareg.TTF) - - shaper := text.NewCache([]text.FontFace{ - { - Font: text.Font{Typeface: "LTR"}, - Face: ltrFace, - }, - { - Font: text.Font{Typeface: "RTL"}, - Face: rtlFace, - }, - }) - ltrSource := "The quick brown fox\njumps over the lazy dog." - rtlSource := "الحب سماء لا\nتمط غير الأحلام" - if runeLimit != 0 { - ltrRunes := []rune(ltrSource) - rtlRunes := []rune(rtlSource) - if runeLimit < len(ltrRunes) { - ltrSource = string(ltrRunes[:runeLimit]) - } - if runeLimit < len(rtlRunes) { - rtlSource = string(rtlRunes[:runeLimit]) - } - } - ltrText := shaper.LayoutString(text.Font{Typeface: "LTR"}, fixed.I(fontSize), lineWidth, english, ltrSource) - rtlText := shaper.LayoutString(text.Font{Typeface: "RTL"}, fixed.I(fontSize), lineWidth, arabic, rtlSource) - return ltrText, rtlText -} - -func TestFirstPos(t *testing.T) { - fontSize := 16 - lineWidth := fontSize * 10 - ltrText, rtlText := makeTestText(fontSize, lineWidth, 0) - type testcase struct { - name string - line text.Line - xAlignMap map[text.Alignment]map[int]fixed.Int26_6 - expected combinedPos - } - for _, tc := range []testcase{ - { - name: "ltr line 0", - line: ltrText[0], - xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{ - text.Start: { - lineWidth: 0, - lineWidth * 2: 0, - }, - text.Middle: { - lineWidth: fixed.I(8), - lineWidth * 2: fixed.I(88), - }, - text.End: { - lineWidth: fixed.I(16), - lineWidth * 2: fixed.I(176), - }, - }, - expected: combinedPos{ - x: 0, - y: ltrText[0].Ascent.Ceil(), - }, - }, - { - name: "ltr line 1", - line: ltrText[1], - xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{ - text.Start: { - lineWidth: 0, - lineWidth * 2: 0, - }, - text.Middle: { - lineWidth: fixed.I(8), - lineWidth * 2: fixed.I(88), - }, - text.End: { - lineWidth: fixed.I(16), - lineWidth * 2: fixed.I(176), - }, - }, - expected: combinedPos{ - x: 0, - y: ltrText[1].Ascent.Ceil(), - }, - }, - { - name: "rtl line 0", - line: rtlText[0], - xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{ - text.End: { - lineWidth: rtlText[0].Width, - lineWidth * 2: rtlText[0].Width, - }, - text.Middle: { - lineWidth: fixed.Int26_6(7827), - lineWidth * 2: fixed.Int26_6(12947), - }, - text.Start: { - lineWidth: fixed.Int26_6(10195), - lineWidth * 2: fixed.Int26_6(20435), - }, - }, - expected: combinedPos{ - x: 0, - y: rtlText[0].Ascent.Ceil(), - }, - }, - { - name: "rtl line 1", - line: rtlText[1], - xAlignMap: map[text.Alignment]map[int]fixed.Int26_6{ - text.End: { - lineWidth: rtlText[1].Width, - lineWidth * 2: rtlText[1].Width, - }, - text.Middle: { - lineWidth: fixed.Int26_6(8184), - lineWidth * 2: fixed.Int26_6(13304), - }, - text.Start: { - lineWidth: fixed.Int26_6(10232), - lineWidth * 2: fixed.Int26_6(20472), - }, - }, - expected: combinedPos{ - x: 0, - y: rtlText[1].Ascent.Ceil(), - }, - }, - } { - for align, cases := range tc.xAlignMap { - for width, expectedX := range cases { - t.Run(tc.name+" "+align.String()+" "+strconv.Itoa(width), func(t *testing.T) { - actual := firstPos(tc.line, align, width) - tc.expected.x = expectedX - if tc.expected.x != actual.x { - t.Errorf("expected x=%s(%d), got %s(%d)", tc.expected.x, tc.expected.x, actual.x, actual.x) - } - if tc.expected.y != actual.y { - t.Errorf("expected y=%d(%d), got %d(%d)", tc.expected.y, tc.expected.y, actual.y, actual.y) - } - }) - } - } - } -} - -func TestIncrementPosition(t *testing.T) { - fontSize := 16 - lineWidth := fontSize * 3 - ltrText, rtlText := makeTestText(fontSize, lineWidth, 0) - type trial struct { - input, output combinedPos - } - type testcase struct { - name string - align text.Alignment - width int - lines []text.Line - firstInput combinedPos - check func(t *testing.T, iteration int, input, output combinedPos, end bool) - } - for _, tc := range []testcase{ - { - name: "ltr", - align: text.Start, - width: lineWidth, - lines: ltrText, - firstInput: firstPos(ltrText[0], text.Start, lineWidth), - }, - { - name: "rtl", - align: text.Start, - width: lineWidth, - lines: rtlText, - firstInput: firstPos(rtlText[0], text.Start, lineWidth), - }, - } { - t.Run(tc.name, func(t *testing.T) { - input := tc.firstInput - for i := 0; true; i++ { - output, end := incrementPosition(tc.lines, tc.align, tc.width, input) - finalRunes := tc.lines[len(tc.lines)-1].Layout.Runes - finalRune := finalRunes.Count + finalRunes.Offset - if end && output.runes != finalRune { - t.Errorf("iteration %d ended prematurely. Has runes %d, expected %d", i, output.runes, finalRune) - } - if end { - break - } - if input == output { - t.Errorf("iteration %d: identical output:\ninput: %#+v\noutput: %#+v", i, input, output) - } - // We should always advance on either the X or Y axis. - if input.y == output.y { - expectedAdvance := tc.lines[input.lineCol.Y].Layout.Clusters[input.clusterIndex].Advance != 0 - rtl := tc.lines[input.lineCol.Y].Layout.Direction.Progression() == system.TowardOrigin - if expectedAdvance { - if (rtl && input.x <= output.x) || (!rtl && input.x >= output.x) { - t.Errorf("iteration %d advanced the wrong way on x axis: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x) - } - } else if input.x != output.x { - t.Errorf("iteration %d advanced x axis when it should not have: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x) - } - // If we stayed on the same line, the line-local rune count should - // be incremented. - if input.lineCol.X >= output.lineCol.X { - t.Errorf("iteration %d advanced lineCol.X incorrectly: input %d output %d", i, input.lineCol.X, output.lineCol.X) - } - // We don't necessarily increment clusters every time, but it should never - // go down. - if input.clusterIndex > output.clusterIndex { - t.Errorf("iteration %d advanced clusterIndex incorrectly: input %d output %d", i, input.clusterIndex, output.clusterIndex) - } - if output.runes != input.runes+1 { - t.Errorf("iteration %d advanced runes incorrectly: input %d output %d", i, input.runes, output.runes) - } - } else { - if input.y >= output.y { - t.Errorf("iteration %d advanced the wrong way on y axis: input %v(%d) output %v(%d)", i, input.y, input.y, output.y, output.y) - } else { - // We correctly advanced on Y axis, so X should be reset to "start of line" - // for the text direction. - rtl := tc.lines[input.lineCol.Y].Layout.Direction.Progression() == system.TowardOrigin - if (rtl && input.x >= output.x) || (!rtl && input.x <= output.x) { - t.Errorf("iteration %d reset x axis incorrectly: input %v(%d) output %v(%d)", i, input.x, input.x, output.x, output.x) - } - } - if input.lineCol.Y >= output.lineCol.Y { - t.Errorf("iteration %d advanced lineCol.Y incorrectly: input %d output %d", i, input.lineCol.Y, output.lineCol.Y) - } - if output.clusterIndex != 0 { - t.Errorf("iteration %d should have zeroed clusterIndex, got: %d", i, output.clusterIndex) - } - if output.lineCol.X != 0 { - t.Errorf("iteration %d should have zeroed lineCol.X, got: %d", i, output.lineCol.X) - } - } - input = output - } - }) - } -} - -func TestClusterIndexFor(t *testing.T) { - fontSize := 16 - lineWidth := fontSize * 3 - ltrText, rtlText := makeTestText(fontSize, lineWidth, 0) - type input struct { - runeIdx int - clusterStartIdx int - expected int - panics bool - } - type testcase struct { - name string - line text.Line - inputs []input - } - for _, tc := range []testcase{ - { - name: "ltr", - line: ltrText[0], - inputs: []input{ - {runeIdx: 0, clusterStartIdx: 0, expected: 0}, - {runeIdx: 1, clusterStartIdx: 0, expected: 1}, - {runeIdx: 1, clusterStartIdx: 1, expected: 1}, - {runeIdx: 1, clusterStartIdx: 2, panics: true}, - {runeIdx: 2, clusterStartIdx: 0, expected: 2}, - {runeIdx: 2, clusterStartIdx: 1, expected: 2}, - {runeIdx: 2, clusterStartIdx: 2, expected: 2}, - {runeIdx: 3, clusterStartIdx: 0, expected: 3}, - {runeIdx: 3, clusterStartIdx: 1, expected: 3}, - {runeIdx: 3, clusterStartIdx: 2, expected: 3}, - {runeIdx: 3, clusterStartIdx: 3, expected: 3}, - {runeIdx: 4, clusterStartIdx: 0, expected: 4}, - {runeIdx: 4, clusterStartIdx: 1, expected: 4}, - {runeIdx: 4, clusterStartIdx: 2, expected: 4}, - {runeIdx: 4, clusterStartIdx: 3, expected: 4}, - {runeIdx: 4, clusterStartIdx: 4, expected: 4}, - {runeIdx: 5, panics: true}, - }, - }, - { - name: "rtl", - line: rtlText[0], - inputs: []input{ - {runeIdx: 0, clusterStartIdx: 0, expected: 0}, - {runeIdx: 1, clusterStartIdx: 0, expected: 1}, - {runeIdx: 1, clusterStartIdx: 1, expected: 1}, - {runeIdx: 1, clusterStartIdx: 2, panics: true}, - {runeIdx: 2, clusterStartIdx: 0, expected: 2}, - {runeIdx: 2, clusterStartIdx: 1, expected: 2}, - {runeIdx: 2, clusterStartIdx: 2, expected: 2}, - {runeIdx: 3, clusterStartIdx: 0, expected: 3}, - {runeIdx: 3, clusterStartIdx: 1, expected: 3}, - {runeIdx: 3, clusterStartIdx: 2, expected: 3}, - {runeIdx: 3, clusterStartIdx: 3, expected: 3}, - {runeIdx: 4, clusterStartIdx: 0, expected: 4}, - {runeIdx: 4, clusterStartIdx: 1, expected: 4}, - {runeIdx: 4, clusterStartIdx: 2, expected: 4}, - {runeIdx: 4, clusterStartIdx: 3, expected: 4}, - {runeIdx: 4, clusterStartIdx: 4, expected: 4}, - {runeIdx: 5, clusterStartIdx: 0, expected: 5}, - {runeIdx: 6, panics: true}, - }, - }, - { - name: "rtl-ligatures", - line: rtlText[4], - inputs: []input{ - {runeIdx: 0, clusterStartIdx: 0, expected: 0}, - {runeIdx: 1, clusterStartIdx: 0, expected: 1}, - {runeIdx: 1, clusterStartIdx: 1, expected: 1}, - {runeIdx: 2, clusterStartIdx: 0, expected: 1}, - {runeIdx: 2, clusterStartIdx: 1, expected: 1}, - {runeIdx: 2, clusterStartIdx: 2, panics: true}, - {runeIdx: 3, clusterStartIdx: 0, expected: 2}, - {runeIdx: 3, clusterStartIdx: 1, expected: 2}, - {runeIdx: 3, clusterStartIdx: 2, expected: 2}, - {runeIdx: 4, clusterStartIdx: 0, expected: 3}, - {runeIdx: 4, clusterStartIdx: 1, expected: 3}, - {runeIdx: 4, clusterStartIdx: 2, expected: 3}, - {runeIdx: 4, clusterStartIdx: 3, expected: 3}, - {runeIdx: 5, clusterStartIdx: 0, expected: 3}, - {runeIdx: 5, clusterStartIdx: 1, expected: 3}, - {runeIdx: 5, clusterStartIdx: 2, expected: 3}, - {runeIdx: 5, clusterStartIdx: 3, expected: 3}, - {runeIdx: 6, clusterStartIdx: 0, expected: 4}, - {runeIdx: 6, clusterStartIdx: 1, expected: 4}, - {runeIdx: 6, clusterStartIdx: 2, expected: 4}, - {runeIdx: 6, clusterStartIdx: 3, expected: 4}, - {runeIdx: 6, clusterStartIdx: 4, expected: 4}, - {runeIdx: 7, clusterStartIdx: 0, expected: 5}, - {runeIdx: 8, panics: true}, - }, - }, - } { - for i, input := range tc.inputs { - t.Run(tc.name+strconv.Itoa(i), func(t *testing.T) { - defer func() { - err := recover() - if err != nil != input.panics { - t.Errorf("panic state mismatch") - } - }() - actual := clusterIndexFor(tc.line, input.runeIdx, input.clusterStartIdx) - if actual != input.expected { - t.Errorf("input[%d]: expected %d, got %d", i, input.expected, actual) - } - }) - } - } -} - -func TestPositionGreaterOrEqual(t *testing.T) { - fontSize := 16 - lineWidth := fontSize * 10 - // Be careful tuning the runeLimit here. This test case's complexity - // is O(N^2) where N=runeLimit. It's easy to make this test take a stupid - // amount of time accidentally. - ltrText, rtlText := makeTestText(fontSize, lineWidth, 15) - type testcase struct { - name string - lines []text.Line - align text.Alignment - width int - } - for _, tc := range []testcase{ - { - name: "ltr", - lines: ltrText, - align: text.Start, - width: lineWidth, - }, - { - name: "rtl", - lines: rtlText, - align: text.Start, - width: lineWidth, - }, - } { - finalLineRunes := tc.lines[len(tc.lines)-1].Layout.Runes - // Statically generate all valid positions. - positions := make([]combinedPos, finalLineRunes.Offset+finalLineRunes.Count+1) - positions[0] = firstPos(tc.lines[0], tc.align, tc.width) - for i := 1; i < len(positions); i++ { - positions[i], _ = incrementPosition(tc.lines, tc.align, tc.width, positions[i-1]) - } - // For each valid position, check every other valid position returns the correct - // result with each permutation of populated fields. - for i, p1 := range positions { - for k, p2 := range positions { - t.Run(tc.name+" "+strconv.Itoa(i)+">="+strconv.Itoa(k), func(t *testing.T) { - for kind := 0; kind < 3; kind++ { - p2 := p2 - transform := "" - switch kind { - case 0: // only runes populated - transform = "runes only" - p2.lineCol = screenPos{} - p2.x = 0 - p2.y = 0 - case 1: // only lineCol populated - transform = "lineCol only" - p2.runes = 0 - p2.x = 0 - p2.y = 0 - case 2: // only x and y populated - transform = "x,y only" - p2.runes = 0 - p2.lineCol = screenPos{} - } - isGreaterOrEqual := i >= k - result := positionGreaterOrEqual(tc.lines, p1, p2) - if result != isGreaterOrEqual { - t.Errorf("unexpected result comparing p[%d] >= p[%d](%s) (%v)\np1: %#+v\np2:%#+v", i, k, transform, result, p1, p2) - } - } - }) - } - } - } -} - -func TestSeekPosition(t *testing.T) { - fontSize := 16 - lineWidth := fontSize * 10 - // Be careful tuning the runeLimit here. This test case's complexity - // is O(N^2) where N=runeLimit. It's easy to make this test take a stupid - // amount of time accidentally. - ltrText, rtlText := makeTestText(fontSize, lineWidth, 15) - type testcase struct { - name string - lines []text.Line - align text.Alignment - width int - } - for _, tc := range []testcase{ - { - name: "ltr", - lines: ltrText, - align: text.Start, - width: lineWidth, - }, - { - name: "rtl", - lines: rtlText, - align: text.Start, - width: lineWidth, - }, - } { - t.Run(tc.name, func(t *testing.T) { - finalLineRunes := tc.lines[len(tc.lines)-1].Layout.Runes - // Statically generate all valid positions. - positions := make([]combinedPos, 1, finalLineRunes.Offset+finalLineRunes.Count+1) - positions[0] = firstPos(tc.lines[0], tc.align, tc.width) - for i := 1; ; i++ { - pos, eof := incrementPosition(tc.lines, tc.align, tc.width, positions[i-1]) - positions = append(positions, pos) - if eof { - break - } - } - for i, start := range positions { - for k, needle := range positions { - if k < i { - continue - } - t.Run(tc.name+" "+strconv.Itoa(i)+"->"+strconv.Itoa(k), func(t *testing.T) { - for kind := 0; kind < 3; kind++ { - p2 := needle - transform := "" - switch kind { - case 0: // only runes populated - transform = "runes only" - p2.lineCol = screenPos{} - p2.x = 0 - p2.y = 0 - case 1: // only lineCol populated - transform = "lineCol only" - p2.runes = 0 - p2.x = 0 - p2.y = 0 - case 2: // only x and y populated - transform = "x,y only" - p2.runes = 0 - p2.lineCol = screenPos{} - } - for limit := 0; limit <= 10; limit += 5 { - result, found := seekPosition(tc.lines, tc.align, tc.width, start, p2, limit) - if (found && result != needle) || (!found && needle.runes-start.runes < limit) { - t.Errorf("unexpected result seeking p[%d] -> p[%d](%s) (limit %d, found %v) = %#+v\np1: %#+v\np2:%#+v", i, k, transform, limit, found, result, start, p2) - } - } - } - }) - } - } - result, found := seekPosition(tc.lines, tc.align, tc.width, positions[0], combinedPos{runes: math.MaxInt}, 0) - if !found { - t.Errorf("reported hit limit on max int") - } - if expected := positions[len(positions)-1]; result != expected { - t.Errorf("expected maximum int position to equal final position.\nexpected %#+v\nactual %#+v", expected, result) - } - }) - } -}