~eliasnaur/gio

b7d126e24cd727a7ca42abf14dbd45188880ecc8 — Chris Waldon 7 months ago 5132501
font/{gofont,opentype},text,widget{,/material}: [API] add font fallback and bidi support

This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.

The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:

    // Prepare some fonts.
    var collection []text.FontFace
    // Make a shaper with those fonts loaded.
    shaper := text.NewShaper(collection)
    // Shape a string.
    shaper.LayoutString(text.Parameters{
		PxPerEm: fixed.I(12),
    }, 0, 100, system.Locale{}, "Hello")
    // Iterate the glyphs from that string.
    for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
    	// Convert the glyph data into a path. In real uses, convert batches of glyphs
    	// rather than single glyphs to reduce the number of individual paths and offsets
    	// required to display your text.
    	shape := shaper.Shape([]text.Glyph{glyph})
    	// Offset the glyph to the position it declares within its fields. This will
    	// automatically handle correct bidirectional text glyph positioning.
    	offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
    	// Create a clip area from the shape of the glyph.
    	area := clip.Outline{Path: shape}.Push(gtx.Ops)
    	// Paint whatever the current color is within the glyph's shape.
    	paint.PaintOp{}.Add(gtx.Ops)
    	area.Pop()
        offset.Pop()
    }

This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.

Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.

This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.

As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.

Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
43 files changed, 3495 insertions(+), 3952 deletions(-)

M app/ime_test.go
D font/opentype/internal/shaping.go
D font/opentype/internal/shaping_test.go
M font/opentype/opentype.go
D font/opentype/opentype_test.go
D font/opentype/testdata/only1.ttf.gz
D font/opentype/testdata/only2.ttf.gz
M go.mod
M go.sum
A text/gotext.go
A text/gotext_test.go
M text/lru.go
M text/lru_test.go
M text/shaper.go
M text/shaper_test.go
A text/testdata/fuzz/FuzzLayout/2a7730fcbcc3550718b37415eb6104cf09159c7d756cc3530ccaa9007c0a2c06
A text/testdata/fuzz/FuzzLayout/3fc3ee939f0df44719bee36a67df3aa3a562e2f2f1cfb665a650f60e7e0ab4e6
A text/testdata/fuzz/FuzzLayout/594e4fda2e3462061d50b6a74279d7e3e486d8ac211e45049cfa4833257ef236
A text/testdata/fuzz/FuzzLayout/6b452fd81f16c000dbe525077b6f4ba91b040119d82d55991d568014f4ba671e
A text/testdata/fuzz/FuzzLayout/6b5a1e9cd750a9aa8dafe11bc74e2377c0664f64bbf2ac8b1cdd0ce9f55461b3
A text/testdata/fuzz/FuzzLayout/be56090b98f3c84da6ff9e857d5487d1a89309f7312ccde5ff8b94fcd29521eb
A text/testdata/fuzz/FuzzLayout/dda958d1b1bb9df71f81c575cb8900f97fc1c20e19de09fc0ffca8ff0c8d9e5a
A text/testdata/fuzz/FuzzLayout/de31ec6b1ac7797daefecf39baaace51fa4348bed0e3b662dd50aa341f67d10c
A text/testdata/fuzz/FuzzLayout/e79034d6c7a6ce74d7a689f4ea99e50a6ecd0d4134e3a77234172d13787ac4ea
A text/testdata/fuzz/FuzzLayout/f1f0611baadc20469863e483f58a3ca4eba895087316b31e598f0b05c978d87c
A text/testdata/fuzz/FuzzLayout/f2ebea678c72f6c394d7f860599b281593098b0aad864231d756c3e9699029c1
M text/text.go
M widget/editor.go
M widget/editor_test.go
A widget/index.go
A widget/index_test.go
M widget/label.go
M widget/material/button.go
M widget/material/checkable.go
M widget/material/editor.go
M widget/material/label.go
M widget/material/theme.go
A widget/testdata/fuzz/FuzzEditorEditing/18c534da60e6b61361786a120fe7b82978ac0e5c16afb519beb080f73c470d3f
A widget/testdata/fuzz/FuzzEditorEditing/a489f2d9f9226d13b55846645d025ac15316f1210d4aa307cde333cc87fdad55
A widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash1
A widget/testdata/fuzz/FuzzEditorEditing/clusterIndexForCrash2
M widget/text_bench_test.go
D widget/text_test.go
M app/ime_test.go => app/ime_test.go +1 -1
@@ 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()


D font/opentype/internal/shaping.go => font/opentype/internal/shaping.go +0 -521
@@ 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
}

D font/opentype/internal/shaping_test.go => font/opentype/internal/shaping_test.go +0 -1522
@@ 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), // <space>   - 1 byte
			4: glyph(4), // lig       - 3 runes, 3 bytes
			// DROP                   - 4 runes, 4 bytes
			5:  glyph(11), // <space> - 1 byte
			6:  glyph(12), // 안      - 4 bytes
			7:  glyph(13), // П       - 3 bytes
			8:  glyph(14), // 你      - 4 bytes
			9:  glyph(15), // <space> - 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), // <space> - 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),  // <space> - 1 byte
			8: glyph(6),  // LIGATURE of three runes:
			//               ا - 3 bytes
			//               ل - 3 bytes
			//               ه - 3 bytes
			9:  glyph(5), // أ - 3 bytes
			10: glyph(4), // <space> - 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)
			}
		})
	}
}

M font/opentype/opentype.go => font/opentype/opentype.go +10 -117
@@ 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
}

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)
	return Face{face: face}, nil
}

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
}

D font/opentype/opentype_test.go => font/opentype/opentype_test.go +0 -45
@@ 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)
	}
}

D font/opentype/testdata/only1.ttf.gz => font/opentype/testdata/only1.ttf.gz +0 -0
D font/opentype/testdata/only2.ttf.gz => font/opentype/testdata/only2.ttf.gz +0 -0
M go.mod => go.mod +4 -4
@@ 6,12 6,12 @@ require (
	eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3
	gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2
	gioui.org/shader v1.0.6
	github.com/benoitkugler/textlayout v0.1.3
	github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d
	github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b
	github.com/benoitkugler/textlayout v0.2.0
	github.com/go-text/typesetting v0.0.0-20221111143014-22bec069817d
	golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
	golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
	golang.org/x/image v0.0.0-20220722155232-062f8c9fd539
	golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64
)

require golang.org/x/text v0.3.7 // indirect
require golang.org/x/text v0.3.7

M go.sum => go.sum +8 -8
@@ 6,18 6,18 @@ gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/benoitkugler/textlayout v0.1.3 h1:Jv0E28xDkke3KrWle90yOLtBmZsUqXLBy70lZRfbKN0=
github.com/benoitkugler/textlayout v0.1.3/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout v0.2.0 h1:I+s1LuIKckIJgDbsGF1iUNEZ24HZ8mMfb2LsMJU+Uko=
github.com/benoitkugler/textlayout v0.2.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1 h1:AvFxBxpfrQd8v55qH59mZOJOQjtD6K2SFe9/HvnIbJk=
github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d h1:ro1W5kY1pVBLHy4GokZUfr9cl7ewZhAiT5WsXqFDYE4=
github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d/go.mod h1:b6uGh9ySJPVQG/RdiI88bE5sUGDk6vzzRujv1BAeuJc=
github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b h1:WINlj3ANt+CVrO2B4NGDHRlPvEWZPxjhb7z+JKypwXI=
github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b/go.mod h1:ZNYu5saGoMOqtkVH5T8onTwhzenDUVszI+5WFHJRaxQ=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/go-text/typesetting v0.0.0-20221111143014-22bec069817d h1:aAOmUgKRf9Rg2OsWezNWgsEjEtCIcFReQgXH81LrldI=
github.com/go-text/typesetting v0.0.0-20221111143014-22bec069817d/go.mod h1:RO32JTaCKQV+XI9psTihlQhZsQJp0R4BIkJvHQGsuVo=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

A text/gotext.go => text/gotext.go +782 -0
@@ 0,0 1,782 @@
// SPDX-License-Identifier: Unlicense OR MIT

package text

import (
	"io"
	"sort"

	"github.com/benoitkugler/textlayout/fonts"
	"github.com/benoitkugler/textlayout/language"
	"github.com/go-text/typesetting/di"
	"github.com/go-text/typesetting/font"
	"github.com/go-text/typesetting/shaping"
	"golang.org/x/exp/slices"
	"golang.org/x/image/math/fixed"
	"golang.org/x/text/unicode/bidi"

	"gioui.org/f32"
	"gioui.org/io/system"
	"gioui.org/op"
	"gioui.org/op/clip"
)

// document holds a collection of shaped lines and alignment information for
// those lines.
type document struct {
	lines     []line
	alignment Alignment
	// alignWidth is the width used when aligning text.
	alignWidth int
}

// append adds the lines of other to the end of l and ensures they
// are aligned to the same width.
func (l *document) append(other document) {
	l.lines = append(l.lines, other.lines...)
	l.alignWidth = max(l.alignWidth, other.alignWidth)
	calculateYOffsets(l.lines)
}

// reset empties the document in preparation to reuse its memory.
func (l *document) reset() {
	l.lines = l.lines[:0]
	l.alignment = Start
	l.alignWidth = 0
}

func max(a, b int) int {
	if a > 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
}

A text/gotext_test.go => text/gotext_test.go +650 -0
@@ 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)
		}
	}
}

M text/lru.go => text/lru.go +69 -26
@@ 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

M text/lru_test.go => text/lru_test.go +4 -3
@@ 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)

M text/shaper.go => text/shaper.go +353 -123
@@ 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

type faceCache struct {
	face        Face
	layoutCache layoutCache
	pathCache   pathCache
	seed        maphash.Seed
}
	// 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

func (c *Cache) lookup(font Font) *faceCache {
	f := c.faceForStyle(font)
	if f == nil {
		font.Typeface = c.def
		f = c.faceForStyle(font)
	}
	return f
	// 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
}

func (c *Cache) faceForStyle(font Font) *faceCache {
	if closest, ok := c.closestFont(font); ok {
		return c.faces[closest]
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("_")
	}
	font.Style = Regular
	if closest, ok := c.closestFont(font); ok {
		return c.faces[closest]
	if f&FlagTowardOrigin > 0 {
		b.WriteString("T")
	} else {
		b.WriteString("_")
	}
	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
	if f&FlagLineBreak > 0 {
		b.WriteString("L")
	} else {
		b.WriteString("_")
	}
	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
		}
	if f&FlagRunBreak > 0 {
		b.WriteString("R")
	} else {
		b.WriteString("_")
	}
	if f&FlagClusterBreak > 0 {
		b.WriteString("C")
	} else {
		b.WriteString("_")
	}
	return match, found
	return b.String()
}

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}
type GlyphID uint64

// Shaper converts strings of text into glyphs that can be displayed.
type Shaper struct {
	shaper      shaperImpl
	pathCache   pathCache
	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
}

// 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 c
	return l
}

// 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, "")
}

// 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 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)
}

// 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)
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
}

// 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)
// 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 (f *faceCache) layout(ppem fixed.Int26_6, maxWidth int, lc system.Locale, str string) []Line {
	if f == nil {
		return nil
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:     ppem,
		ppem:     params.PxPerEm,
		maxWidth: maxWidth,
		str:      str,
		minWidth: minWidth,
		maxLines: params.MaxLines,
		str:      asStr,
		locale:   lc,
		font:     params.Font,
	}
	if l, ok := f.layoutCache.Get(lk); ok {
	if l, ok := l.layoutCache.Get(lk); ok {
		return l
	}
	l, _ := f.face.Layout(ppem, maxWidth, lc, strings.NewReader(str))
	f.layoutCache.Put(lk, l)
	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
}

// 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()
// 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
	}
	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[:])
	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
	}
	return h.Sum64()
}

func (f *faceCache) shape(ppem fixed.Int26_6, layout Layout) clip.PathSpec {
	if f == nil {
		return clip.PathSpec{}
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)-1) != 0 {
		fmt.Println(gid)
		panic("glyph id out of bounds")
	}
	if faceIdx&^((1<<facebits)-1) != 0 {
		panic("face index out of bounds")
	}
	pk := pathKey{
		ppem:    ppem,
		gidHash: f.hashGIDs(layout),
	if ppem&^((1<<sizebits)-1) != 0 {
		panic("ppem out of bounds")
	}
	if clip, ok := f.pathCache.Get(pk, layout); ok {
		return clip
	// Mask off the upper 16 bits of ppem. This still allows values up to
	// 1023.
	ppem &= ((1 << sizebits) - 1)
	return GlyphID(faceIdx)<<(gidbits+sizebits) | GlyphID(ppem)<<(gidbits) | GlyphID(gid)
}

// splitGlyphID is the opposite of newGlyphID.
func splitGlyphID(g GlyphID) (fixed.Int26_6, int, font.GID) {
	faceIdx := int(g) >> (gidbits + sizebits)
	ppem := fixed.Int26_6((g & ((1<<sizebits - 1) << gidbits)) >> gidbits)
	gid := font.GID(g) & (1<<gidbits - 1)
	return ppem, faceIdx, gid
}

// Shape converts a slice of glyphs into a path describing their collective
// shape. All glyphs are expected to be from a single line of text (their
// Y offsets are ignored).
func (l *Shaper) Shape(gs []Glyph) clip.PathSpec {
	key := l.pathCache.hashGlyphs(gs)
	shape, ok := l.pathCache.Get(key, gs)
	if ok {
		return shape
	}
	clip := f.face.Shape(ppem, layout)
	f.pathCache.Put(pk, layout, clip)
	return clip
	ops := new(op.Ops)
	shape = l.shaper.Shape(ops, gs)
	l.pathCache.Put(key, gs, shape)
	return shape
}

M text/shaper_test.go => text/shaper_test.go +183 -98
@@ 2,117 2,202 @@ package text

import (
	"testing"
)

var (
	testTF1 Typeface = "MockFace"
	testTF2 Typeface = "TestFace"
	testTF3 Typeface = "AnotherFace"
	nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
	"gioui.org/font/opentype"
	"gioui.org/io/system"
	"golang.org/x/image/font/gofont/goregular"
	"golang.org/x/image/math/fixed"
)

func TestClosestFontByWeight(t *testing.T) {
	c := newTestCache(
		Font{Style: Regular, Weight: Normal},
		Font{Style: Regular, Weight: Light},
		Font{Style: Regular, Weight: Bold},
		Font{Style: Italic, Weight: Thin},
	)
	weightOnlyTests := []struct {
		Lookup   Weight
		Expected Weight
	}{
		// Test for existing weights.
		{Lookup: Normal, Expected: Normal},
		{Lookup: Light, Expected: Light},
		{Lookup: Bold, Expected: Bold},
		// Test for missing weights.
		{Lookup: Thin, Expected: Light},
		{Lookup: ExtraLight, Expected: Light},
		{Lookup: Medium, Expected: Normal},
		{Lookup: SemiBold, Expected: Bold},
		{Lookup: ExtraBlack, Expected: Bold},
// TestCacheEmptyString ensures that shaping the empty string returns a
// single synthetic glyph with ascent/descent info.
func TestCacheEmptyString(t *testing.T) {
	ltrFace, _ := opentype.Parse(goregular.TTF)
	collection := []FontFace{{Face: ltrFace}}
	cache := NewShaper(collection)
	cache.LayoutString(Parameters{
		Alignment: Middle,
		PxPerEm:   fixed.I(10),
	}, 200, 200, english, "")
	glyphs := make([]Glyph, 0, 1)
	for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
		glyphs = append(glyphs, g)
	}
	for _, test := range weightOnlyTests {
		got, ok := c.closestFont(Font{Typeface: testTF1, Weight: test.Lookup})
		if !ok {
			t.Fatalf("expected closest font for %v to exist", test.Lookup)
		}
		if got.Weight != test.Expected {
			t.Fatalf("got weight %v, expected %v", got.Weight, test.Expected)
		}
	if len(glyphs) != 1 {
		t.Errorf("expected %d glyphs, got %d", 1, len(glyphs))
	}
	c = newTestCache(
		Font{Style: Regular, Weight: Light},
		Font{Style: Regular, Weight: Bold},
		Font{Style: Italic, Weight: Normal},
		Font{Typeface: testTF3, Style: Italic, Weight: Bold},
	)
	otherTests := []struct {
		Lookup         Font
		Expected       Font
		ExpectedToFail bool
	}{
		// Test for existing fonts.
		{
			Lookup:   Font{Typeface: testTF1, Weight: Light},
			Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
		},
		{
			Lookup:   Font{Typeface: testTF1, Style: Italic, Weight: Normal},
			Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
		},
		// Test for missing fonts.
		{
			Lookup:   Font{Typeface: testTF1, Weight: Normal},
			Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
		},
		{
			Lookup:   Font{Typeface: testTF3, Style: Italic, Weight: Normal},
			Expected: Font{Typeface: testTF3, Style: Italic, Weight: Bold},
		},
		{
			Lookup:   Font{Typeface: testTF1, Style: Italic, Weight: Thin},
			Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
		},
		{
			Lookup:   Font{Typeface: testTF1, Style: Italic, Weight: Bold},
			Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
		},
	glyph := glyphs[0]
	checkFlag(t, true, FlagClusterBreak, glyph, 0)
	checkFlag(t, true, FlagRunBreak, glyph, 0)
	checkFlag(t, true, FlagLineBreak, glyph, 0)
	checkFlag(t, true, FlagSynthetic, glyph, 0)
	if glyph.Ascent == 0 {
		t.Errorf("expected non-zero ascent")
	}
	if glyph.Descent == 0 {
		t.Errorf("expected non-zero descent")
	}
	if glyph.Y == 0 {
		t.Errorf("expected non-zero y offset")
	}
	if glyph.X == 0 {
		t.Errorf("expected non-zero x offset")
	}
}

// TestCacheAlignment ensures that shaping with different alignments or dominant
// text directions results in different X offsets.
func TestCacheAlignment(t *testing.T) {
	ltrFace, _ := opentype.Parse(goregular.TTF)
	collection := []FontFace{{Face: ltrFace}}
	cache := NewShaper(collection)
	params := Parameters{Alignment: Start, PxPerEm: fixed.I(10)}
	cache.LayoutString(params, 200, 200, english, "A")
	glyph, _ := cache.NextGlyph()
	startX := glyph.X
	params.Alignment = Middle
	cache.LayoutString(params, 200, 200, english, "A")
	glyph, _ = cache.NextGlyph()
	middleX := glyph.X
	params.Alignment = End
	cache.LayoutString(params, 200, 200, english, "A")
	glyph, _ = cache.NextGlyph()
	endX := glyph.X
	if startX == middleX || startX == endX || endX == middleX {
		t.Errorf("[LTR] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", startX, middleX, endX)
	}
	params.Alignment = Start
	cache.LayoutString(params, 200, 200, arabic, "A")
	glyph, _ = cache.NextGlyph()
	rtlStartX := glyph.X
	params.Alignment = Middle
	cache.LayoutString(params, 200, 200, arabic, "A")
	glyph, _ = cache.NextGlyph()
	rtlMiddleX := glyph.X
	params.Alignment = End
	cache.LayoutString(params, 200, 200, arabic, "A")
	glyph, _ = cache.NextGlyph()
	rtlEndX := glyph.X
	if rtlStartX == rtlMiddleX || rtlStartX == rtlEndX || rtlEndX == rtlMiddleX {
		t.Errorf("[RTL] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", rtlStartX, rtlMiddleX, rtlEndX)
	}
	if startX == rtlStartX || endX == rtlEndX {
		t.Errorf("shaping with with different dominant text directions and the same alignment should not produce the same X unless it's middle-aligned")
	}
}

func TestCacheGlyphConverstion(t *testing.T) {
	ltrFace, _ := opentype.Parse(goregular.TTF)
	rtlFace, _ := opentype.Parse(nsareg.TTF)
	collection := []FontFace{{Face: ltrFace}, {Face: rtlFace}}
	type testcase struct {
		name     string
		text     string
		locale   system.Locale
		expected []Glyph
	}
	for _, tc := range []testcase{
		{
			Lookup:         Font{Typeface: testTF2, Weight: Normal},
			ExpectedToFail: true,
			name:   "bidi ltr",
			text:   "The quick سماء שלום لا fox تمط שלום\nغير the\nlazy dog.",
			locale: english,
		},
		{
			Lookup:         Font{Typeface: testTF2, Style: Italic, Weight: Normal},
			ExpectedToFail: true,
			name:   "bidi rtl",
			text:   "الحب سماء brown привет fox تمط jumps\nпривет over\nغير الأحلام.",
			locale: arabic,
		},
	}
	for _, test := range otherTests {
		got, ok := c.closestFont(test.Lookup)
		if test.ExpectedToFail {
			if ok {
				t.Fatalf("expected closest font for %v to not exist", test.Lookup)
			} else {
				continue
	} {
		t.Run(tc.name, func(t *testing.T) {
			cache := NewShaper(collection)
			cache.LayoutString(Parameters{
				PxPerEm: fixed.I(10),
			}, 0, 200, tc.locale, tc.text)
			doc := cache.txt
			glyphs := make([]Glyph, 0, len(tc.expected))
			for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
				glyphs = append(glyphs, g)
			}
		}
		if !ok {
			t.Fatalf("expected closest font for %v to exist", test.Lookup)
		}
		if got != test.Expected {
			t.Fatalf("got %v, expected %v", got, test.Expected)
		}
			glyphCursor := 0
			for _, line := range doc.lines {
				for runIdx, run := range line.runs {
					lastRun := runIdx == len(line.runs)-1
					start := 0
					end := len(run.Glyphs) - 1
					inc := 1
					towardOrigin := false
					if run.Direction.Progression() == system.TowardOrigin {
						start = len(run.Glyphs) - 1
						end = 0
						inc = -1
						towardOrigin = true
					}
					for glyphIdx := start; ; glyphIdx += inc {
						endOfRun := glyphIdx == end
						glyph := run.Glyphs[glyphIdx]
						endOfCluster := glyphIdx == end || run.Glyphs[glyphIdx+inc].clusterIndex != glyph.clusterIndex

						actual := glyphs[glyphCursor]
						if actual.ID != glyph.id {
							t.Errorf("glyphs[%d] expected id %d, got id %d", glyphCursor, glyph.id, actual.ID)
						}
						// Synthetic glyphs should only ever show up at the end of lines.
						endOfLine := lastRun && endOfRun
						synthetic := glyph.glyphCount == 0 && endOfLine
						checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
						checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
						checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
						checkFlag(t, synthetic, FlagSynthetic, actual, glyphCursor)
						checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
						glyphCursor++
						if glyphIdx == end {
							break
						}
					}
				}
			}

			printLinePositioning(t, doc.lines, glyphs)
		})
	}
}

func newTestCache(fonts ...Font) *Cache {
	c := &Cache{faces: make(map[Font]*faceCache)}
	c.def = testTF1
	for _, font := range fonts {
		if font.Typeface == "" {
			font.Typeface = testTF1
func checkFlag(t *testing.T, shouldHave bool, flag Flags, actual Glyph, glyphCursor int) {
	t.Helper()
	if shouldHave && actual.Flags&flag == 0 {
		t.Errorf("glyphs[%d] should have %s set", glyphCursor, flag)
	} else if !shouldHave && actual.Flags&flag != 0 {
		t.Errorf("glyphs[%d] should not have %s set", glyphCursor, flag)
	}
}

func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
	t.Helper()
	glyphCursor := 0
	for i, line := range lines {
		t.Logf("line %d, dir %s, width %d, visual %v, runeCount: %d", i, line.direction, line.width, line.visualOrder, line.runeCount)
		for k, run := range line.runs {
			t.Logf("run: %d, dir %s, width %d, runes {count: %d, offset: %d}", k, run.Direction, run.Advance, run.Runes.Count, run.Runes.Offset)
			start := 0
			end := len(run.Glyphs) - 1
			inc := 1
			if run.Direction.Progression() == system.TowardOrigin {
				start = len(run.Glyphs) - 1
				end = 0
				inc = -1
			}
			for g := start; ; g += inc {
				glyph := run.Glyphs[g]
				if glyphCursor < len(glyphs) {
					t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
					t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
				}
				glyphCursor++
				if g == end {
					break
				}
			}
		}
		c.faces[font] = &faceCache{face: nil}
	}
	return c
}

A text/testdata/fuzz/FuzzLayout/2a7730fcbcc3550718b37415eb6104cf09159c7d756cc3530ccaa9007c0a2c06 => text/testdata/fuzz/FuzzLayout/2a7730fcbcc3550718b37415eb6104cf09159c7d756cc3530ccaa9007c0a2c06 +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\x1d")
bool(true)
byte('\x1c')
uint16(227)

A text/testdata/fuzz/FuzzLayout/3fc3ee939f0df44719bee36a67df3aa3a562e2f2f1cfb665a650f60e7e0ab4e6 => text/testdata/fuzz/FuzzLayout/3fc3ee939f0df44719bee36a67df3aa3a562e2f2f1cfb665a650f60e7e0ab4e6 +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("0")
bool(true)
uint8(27)
uint16(200)

A text/testdata/fuzz/FuzzLayout/594e4fda2e3462061d50b6a74279d7e3e486d8ac211e45049cfa4833257ef236 => text/testdata/fuzz/FuzzLayout/594e4fda2e3462061d50b6a74279d7e3e486d8ac211e45049cfa4833257ef236 +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\u2029")
bool(false)
byte('*')
uint16(72)

A text/testdata/fuzz/FuzzLayout/6b452fd81f16c000dbe525077b6f4ba91b040119d82d55991d568014f4ba671e => text/testdata/fuzz/FuzzLayout/6b452fd81f16c000dbe525077b6f4ba91b040119d82d55991d568014f4ba671e +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("Aͮ000000000000000")
bool(false)
byte('\u0087')
uint16(111)

A text/testdata/fuzz/FuzzLayout/6b5a1e9cd750a9aa8dafe11bc74e2377c0664f64bbf2ac8b1cdd0ce9f55461b3 => text/testdata/fuzz/FuzzLayout/6b5a1e9cd750a9aa8dafe11bc74e2377c0664f64bbf2ac8b1cdd0ce9f55461b3 +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\x1e")
bool(true)
byte('\n')
uint16(254)

A text/testdata/fuzz/FuzzLayout/be56090b98f3c84da6ff9e857d5487d1a89309f7312ccde5ff8b94fcd29521eb => text/testdata/fuzz/FuzzLayout/be56090b98f3c84da6ff9e857d5487d1a89309f7312ccde5ff8b94fcd29521eb +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\r")
bool(false)
byte('T')
uint16(200)

A text/testdata/fuzz/FuzzLayout/dda958d1b1bb9df71f81c575cb8900f97fc1c20e19de09fc0ffca8ff0c8d9e5a => text/testdata/fuzz/FuzzLayout/dda958d1b1bb9df71f81c575cb8900f97fc1c20e19de09fc0ffca8ff0c8d9e5a +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\u0085")
bool(true)
byte('\x10')
uint16(271)

A text/testdata/fuzz/FuzzLayout/de31ec6b1ac7797daefecf39baaace51fa4348bed0e3b662dd50aa341f67d10c => text/testdata/fuzz/FuzzLayout/de31ec6b1ac7797daefecf39baaace51fa4348bed0e3b662dd50aa341f67d10c +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("0")
bool(false)
byte('\x00')
uint16(142)

A text/testdata/fuzz/FuzzLayout/e79034d6c7a6ce74d7a689f4ea99e50a6ecd0d4134e3a77234172d13787ac4ea => text/testdata/fuzz/FuzzLayout/e79034d6c7a6ce74d7a689f4ea99e50a6ecd0d4134e3a77234172d13787ac4ea +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\n")
bool(true)
byte('\t')
uint16(200)

A text/testdata/fuzz/FuzzLayout/f1f0611baadc20469863e483f58a3ca4eba895087316b31e598f0b05c978d87c => text/testdata/fuzz/FuzzLayout/f1f0611baadc20469863e483f58a3ca4eba895087316b31e598f0b05c978d87c +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("ع0 ׂ0")
bool(false)
byte('\u0098')
uint16(198)

A text/testdata/fuzz/FuzzLayout/f2ebea678c72f6c394d7f860599b281593098b0aad864231d756c3e9699029c1 => text/testdata/fuzz/FuzzLayout/f2ebea678c72f6c394d7f860599b281593098b0aad864231d756c3e9699029c1 +5 -0
@@ 0,0 1,5 @@
go test fuzz v1
string("\x1c")
bool(true)
byte('\u009c')
uint16(200)

M text/text.go => text/text.go +29 -135
@@ 3,131 3,13 @@
package text

import (
	"io"
	"fmt"

	"gioui.org/io/system"
	"gioui.org/op/clip"
	"github.com/go-text/typesetting/font"
	"golang.org/x/image/math/fixed"
)

// A Line contains the measurements of a line of text.
type Line struct {
	Layout Layout
	// 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
}

// 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
}

// GlyphID uniquely identifies a glyph within a specific font.
type GlyphID = font.GID

// 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
}

// GlyphCluster provides metadata about a sequence of indivisible shaped
// glyphs.
type GlyphCluster struct {
	// Advance is the cumulative advance of all glyphs in the cluster.
	Advance fixed.Int26_6
	// Runes indicates the position and quantity of the runes represented by
	// this cluster within the text.
	Runes Range
	// Glyphs indicates the position and quantity of the glyphs within this
	// cluster in a Layout's Glyphs slice.
	Glyphs Range
}

// RuneWidth returns the effective width of one rune for this cluster.
// If the cluster contains multiple runes, the width of the glyphs of
// the cluster is divided evenly among the runes.
func (c GlyphCluster) RuneWidth() fixed.Int26_6 {
	if c.Runes.Count == 0 {
		return 0
	}
	return c.Advance / fixed.Int26_6(c.Runes.Count)
}

type Layout struct {
	// 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
	// Clusters are metadata about the shaped glyphs. They are mostly useful for
	// interactive text widgets like editors. The order of clusters is logical,
	// so the first cluster will describe the beginning of the text and may
	// refer to the final glyphs in the Glyphs field if the text is RTL.
	Clusters []GlyphCluster
	// Runes describes the position of the text data this layout represents
	// within the overall body of text being shaped.
	Runes Range
	// Direction is the layout direction of the text.
	Direction system.TextDirection
}

// Slice returns a layout starting at the glyph cluster index start
// and running through the glyph cluster index end. The Offsets field
// of the returned layout is adjusted to reflect the new rune range
// covered by the layout. The returned layout will have no Clusters.
func (l Layout) Slice(start, end int) Layout {
	if start == end || end == 0 || start == len(l.Clusters) {
		return Layout{}
	}
	newRuneStart := l.Clusters[start].Runes.Offset
	runesBefore := newRuneStart - l.Runes.Offset
	endCluster := l.Clusters[end-1]
	startCluster := l.Clusters[start]
	runesAfter := l.Runes.Offset + l.Runes.Count - (endCluster.Runes.Offset + endCluster.Runes.Count)

	if l.Direction.Progression() == system.TowardOrigin {
		startCluster, endCluster = endCluster, startCluster
	}
	glyphStart := startCluster.Glyphs.Offset
	glyphEnd := endCluster.Glyphs.Offset + endCluster.Glyphs.Count

	out := l
	out.Clusters = nil
	out.Glyphs = out.Glyphs[glyphStart:glyphEnd]
	out.Runes.Offset = newRuneStart
	out.Runes.Count -= runesBefore + runesAfter

	return out
}

// Style is the font style.
type Style int



@@ 144,11 26,10 @@ type Font struct {
	Weight Weight
}

// Face implements text layout and shaping for a particular font. All
// methods must be safe for concurrent use.
// Face is an opaque handle to a typeface. The concrete implementation depends
// upon the kind of font and shaper in use.
type Face interface {
	Layout(ppem fixed.Int26_6, maxWidth int, lc system.Locale, txt io.RuneReader) ([]Line, error)
	Shape(ppem fixed.Int26_6, str Layout) clip.PathSpec
	Face() font.Face
}