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
}