github.com/utopiagio/gio@v0.0.8/text/gotext_test.go (about)

     1  package text
     2  
     3  import (
     4  	"fmt"
     5  	"math"
     6  	"reflect"
     7  	"strconv"
     8  	"testing"
     9  
    10  	nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
    11  	"github.com/go-text/typesetting/font"
    12  	"github.com/go-text/typesetting/shaping"
    13  	"golang.org/x/exp/slices"
    14  	"golang.org/x/image/font/gofont/goregular"
    15  	"golang.org/x/image/math/fixed"
    16  
    17  	giofont "github.com/utopiagio/gio/font"
    18  	"github.com/utopiagio/gio/font/opentype"
    19  	"github.com/utopiagio/gio/io/system"
    20  )
    21  
    22  var english = system.Locale{
    23  	Language:  "EN",
    24  	Direction: system.LTR,
    25  }
    26  
    27  var arabic = system.Locale{
    28  	Language:  "AR",
    29  	Direction: system.RTL,
    30  }
    31  
    32  func testShaper(faces ...giofont.Face) *shaperImpl {
    33  	ff := make([]FontFace, 0, len(faces))
    34  	for _, face := range faces {
    35  		ff = append(ff, FontFace{Face: face})
    36  	}
    37  	shaper := newShaperImpl(false, ff)
    38  	return shaper
    39  }
    40  
    41  func TestEmptyString(t *testing.T) {
    42  	ppem := fixed.I(200)
    43  	ltrFace, _ := opentype.Parse(goregular.TTF)
    44  	shaper := testShaper(ltrFace)
    45  
    46  	lines := shaper.LayoutRunes(Parameters{
    47  		PxPerEm:  ppem,
    48  		MaxWidth: 2000,
    49  		Locale:   english,
    50  	}, []rune{})
    51  	if len(lines.lines) == 0 {
    52  		t.Fatalf("Layout returned no lines for empty string; expected 1")
    53  	}
    54  	l := lines.lines[0]
    55  	if expected := fixed.Int26_6(12094); l.ascent != expected {
    56  		t.Errorf("unexpected ascent for empty string: %v, expected %v", l.ascent, expected)
    57  	}
    58  	if expected := fixed.Int26_6(2700); l.descent != expected {
    59  		t.Errorf("unexpected descent for empty string: %v, expected %v", l.descent, expected)
    60  	}
    61  }
    62  
    63  func TestNoFaces(t *testing.T) {
    64  	ppem := fixed.I(200)
    65  	shaper := testShaper()
    66  
    67  	// Ensure shaping text with no faces does not panic.
    68  	shaper.LayoutRunes(Parameters{
    69  		PxPerEm:  ppem,
    70  		MaxWidth: 2000,
    71  		Locale:   english,
    72  	}, []rune("✨ⷽℎ↞⋇ⱜ⪫⢡⽛⣦␆Ⱨⳏ⳯⒛⭣╎⌞⟻⢇┃➡⬎⩱⸇ⷎ⟅▤⼶⇺⩳⎏⤬⬞ⴈ⋠⿶⢒₍☟⽂ⶦ⫰⭢⌹∼▀⾯⧂❽⩏ⓖ⟅⤔⍇␋⽓ₑ⢳⠑❂⊪⢘⽨⃯▴ⷿ"))
    73  }
    74  
    75  func TestAlignWidth(t *testing.T) {
    76  	lines := []line{
    77  		{width: fixed.I(50)},
    78  		{width: fixed.I(75)},
    79  		{width: fixed.I(25)},
    80  	}
    81  	for _, minWidth := range []int{0, 50, 100} {
    82  		width := alignWidth(minWidth, lines)
    83  		if width < minWidth {
    84  			t.Errorf("expected width >= %d, got %d", minWidth, width)
    85  		}
    86  	}
    87  }
    88  
    89  func TestShapingAlignWidth(t *testing.T) {
    90  	ppem := fixed.I(10)
    91  	ltrFace, _ := opentype.Parse(goregular.TTF)
    92  	shaper := testShaper(ltrFace)
    93  
    94  	type testcase struct {
    95  		name               string
    96  		minWidth, maxWidth int
    97  		expected           int
    98  		str                string
    99  	}
   100  	for _, tc := range []testcase{
   101  		{
   102  			name:     "zero min",
   103  			maxWidth: 100,
   104  			str:      "a\nb\nc",
   105  			expected: 22,
   106  		},
   107  		{
   108  			name:     "min == max",
   109  			minWidth: 100,
   110  			maxWidth: 100,
   111  			str:      "a\nb\nc",
   112  			expected: 100,
   113  		},
   114  		{
   115  			name:     "min < max",
   116  			minWidth: 50,
   117  			maxWidth: 100,
   118  			str:      "a\nb\nc",
   119  			expected: 50,
   120  		},
   121  		{
   122  			name:     "min < max, text > min",
   123  			minWidth: 50,
   124  			maxWidth: 100,
   125  			str:      "aphabetic\nb\nc",
   126  			expected: 60,
   127  		},
   128  	} {
   129  		t.Run(tc.name, func(t *testing.T) {
   130  			lines := shaper.LayoutString(Parameters{PxPerEm: ppem,
   131  				MinWidth: tc.minWidth,
   132  				MaxWidth: tc.maxWidth,
   133  				Locale:   english,
   134  			}, tc.str)
   135  			if lines.alignWidth != tc.expected {
   136  				t.Errorf("expected line alignWidth to be %d, got %d", tc.expected, lines.alignWidth)
   137  			}
   138  		})
   139  	}
   140  }
   141  
   142  // TestNewlineSynthesis ensures that the shaper correctly inserts synthetic glyphs
   143  // representing newline runes.
   144  func TestNewlineSynthesis(t *testing.T) {
   145  	ppem := fixed.I(10)
   146  	ltrFace, _ := opentype.Parse(goregular.TTF)
   147  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   148  	shaper := testShaper(ltrFace, rtlFace)
   149  
   150  	type testcase struct {
   151  		name   string
   152  		locale system.Locale
   153  		txt    string
   154  	}
   155  	for _, tc := range []testcase{
   156  		{
   157  			name:   "ltr bidi newline in rtl segment",
   158  			locale: english,
   159  			txt:    "The quick سماء שלום لا fox تمط שלום\n",
   160  		},
   161  		{
   162  			name:   "ltr bidi newline in ltr segment",
   163  			locale: english,
   164  			txt:    "The quick سماء שלום لا fox\n",
   165  		},
   166  		{
   167  			name:   "rtl bidi newline in ltr segment",
   168  			locale: arabic,
   169  			txt:    "الحب سماء brown привет fox تمط jumps\n",
   170  		},
   171  		{
   172  			name:   "rtl bidi newline in rtl segment",
   173  			locale: arabic,
   174  			txt:    "الحب سماء brown привет fox تمط\n",
   175  		},
   176  	} {
   177  		t.Run(tc.name, func(t *testing.T) {
   178  
   179  			doc := shaper.LayoutRunes(Parameters{
   180  				PxPerEm:  ppem,
   181  				MaxWidth: 200,
   182  				Locale:   tc.locale,
   183  			}, []rune(tc.txt))
   184  			for lineIdx, line := range doc.lines {
   185  				lastRunIdx := len(line.runs) - 1
   186  				lastRun := line.runs[lastRunIdx]
   187  				lastGlyphIdx := len(lastRun.Glyphs) - 1
   188  				if lastRun.Direction.Progression() == system.TowardOrigin {
   189  					lastGlyphIdx = 0
   190  				}
   191  				glyph := lastRun.Glyphs[lastGlyphIdx]
   192  				if glyph.glyphCount != 0 {
   193  					t.Errorf("expected synthetic newline on line %d, run %d, glyph %d", lineIdx, lastRunIdx, lastGlyphIdx)
   194  				}
   195  				for runIdx, run := range line.runs {
   196  					for glyphIdx, glyph := range run.Glyphs {
   197  						if runIdx == lastRunIdx && glyphIdx == lastGlyphIdx {
   198  							continue
   199  						}
   200  						if glyph.glyphCount == 0 {
   201  							t.Errorf("found invalid synthetic newline on line %d, run %d, glyph %d", lineIdx, runIdx, glyphIdx)
   202  						}
   203  					}
   204  				}
   205  			}
   206  			if t.Failed() {
   207  				printLinePositioning(t, doc.lines, nil)
   208  			}
   209  		})
   210  	}
   211  
   212  }
   213  
   214  // simpleGlyph returns a simple square glyph with the provided cluster
   215  // value.
   216  func simpleGlyph(cluster int) shaping.Glyph {
   217  	return complexGlyph(cluster, 1, 1)
   218  }
   219  
   220  // ligatureGlyph returns a simple square glyph with the provided cluster
   221  // value and number of runes.
   222  func ligatureGlyph(cluster, runes int) shaping.Glyph {
   223  	return complexGlyph(cluster, runes, 1)
   224  }
   225  
   226  // expansionGlyph returns a simple square glyph with the provided cluster
   227  // value and number of glyphs.
   228  func expansionGlyph(cluster, glyphs int) shaping.Glyph {
   229  	return complexGlyph(cluster, 1, glyphs)
   230  }
   231  
   232  // complexGlyph returns a simple square glyph with the provided cluster
   233  // value, number of associated runes, and number of glyphs in the cluster.
   234  func complexGlyph(cluster, runes, glyphs int) shaping.Glyph {
   235  	return shaping.Glyph{
   236  		Width:        fixed.I(10),
   237  		Height:       fixed.I(10),
   238  		XAdvance:     fixed.I(10),
   239  		YAdvance:     fixed.I(10),
   240  		YBearing:     fixed.I(10),
   241  		ClusterIndex: cluster,
   242  		GlyphCount:   glyphs,
   243  		RuneCount:    runes,
   244  	}
   245  }
   246  
   247  // copyLines performs a deep copy of the provided lines. This is necessary if you
   248  // want to use the line wrapper again while also using the lines.
   249  func copyLines(lines []shaping.Line) []shaping.Line {
   250  	out := make([]shaping.Line, len(lines))
   251  	for lineIdx, line := range lines {
   252  		lineCopy := make([]shaping.Output, len(line))
   253  		for runIdx, run := range line {
   254  			lineCopy[runIdx] = run
   255  			lineCopy[runIdx].Glyphs = slices.Clone(run.Glyphs)
   256  		}
   257  		out[lineIdx] = lineCopy
   258  	}
   259  	return out
   260  }
   261  
   262  // makeTestText creates a simple and complex(bidi) sample of shaped text at the given
   263  // font size and wrapped to the given line width. The runeLimit, if nonzero,
   264  // truncates the sample text to ensure shorter output for expensive tests.
   265  func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize, lineWidth, runeLimit int) (simpleSample, complexSample []shaping.Line) {
   266  	ltrFace, _ := opentype.Parse(goregular.TTF)
   267  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   268  	if shaper == nil {
   269  		shaper = testShaper(ltrFace, rtlFace)
   270  	}
   271  
   272  	ltrSource := "The quick brown fox jumps over the lazy dog."
   273  	rtlSource := "الحب سماء لا تمط غير الأحلام"
   274  	// bidiSource is crafted to contain multiple consecutive RTL runs (by
   275  	// changing scripts within the RTL).
   276  	bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog."
   277  	// bidi2Source is crafted to contain multiple consecutive LTR runs (by
   278  	// changing scripts within the LTR).
   279  	bidi2Source := "الحب سماء brown привет fox تمط jumps привет over غير الأحلام"
   280  
   281  	locale := english
   282  	simpleSource := ltrSource
   283  	complexSource := bidiSource
   284  	if primaryDir == system.RTL {
   285  		simpleSource = rtlSource
   286  		complexSource = bidi2Source
   287  		locale = arabic
   288  	}
   289  	if runeLimit != 0 {
   290  		simpleRunes := []rune(simpleSource)
   291  		complexRunes := []rune(complexSource)
   292  		if runeLimit < len(simpleRunes) {
   293  			ltrSource = string(simpleRunes[:runeLimit])
   294  		}
   295  		if runeLimit < len(complexRunes) {
   296  			rtlSource = string(complexRunes[:runeLimit])
   297  		}
   298  	}
   299  	simpleText, _ := shaper.shapeAndWrapText(Parameters{
   300  		PxPerEm:  fixed.I(fontSize),
   301  		MaxWidth: lineWidth,
   302  		Locale:   locale,
   303  	}, []rune(simpleSource))
   304  	simpleText = copyLines(simpleText)
   305  	complexText, _ := shaper.shapeAndWrapText(Parameters{
   306  		PxPerEm:  fixed.I(fontSize),
   307  		MaxWidth: lineWidth,
   308  		Locale:   locale,
   309  	}, []rune(complexSource))
   310  	complexText = copyLines(complexText)
   311  	testShaper(rtlFace, ltrFace)
   312  	return simpleText, complexText
   313  }
   314  
   315  func fixedAbs(a fixed.Int26_6) fixed.Int26_6 {
   316  	if a < 0 {
   317  		a = -a
   318  	}
   319  	return a
   320  }
   321  
   322  func TestToLine(t *testing.T) {
   323  	ltrFace, _ := opentype.Parse(goregular.TTF)
   324  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   325  	shaper := testShaper(ltrFace, rtlFace)
   326  	ltr, bidi := makeTestText(shaper, system.LTR, 16, 100, 0)
   327  	rtl, bidi2 := makeTestText(shaper, system.RTL, 16, 100, 0)
   328  	_, bidiWide := makeTestText(shaper, system.LTR, 16, 200, 0)
   329  	_, bidi2Wide := makeTestText(shaper, system.RTL, 16, 200, 0)
   330  	type testcase struct {
   331  		name  string
   332  		lines []shaping.Line
   333  		// Dominant text direction.
   334  		dir system.TextDirection
   335  	}
   336  	for _, tc := range []testcase{
   337  		{
   338  			name:  "ltr",
   339  			lines: ltr,
   340  			dir:   system.LTR,
   341  		},
   342  		{
   343  			name:  "rtl",
   344  			lines: rtl,
   345  			dir:   system.RTL,
   346  		},
   347  		{
   348  			name:  "bidi",
   349  			lines: bidi,
   350  			dir:   system.LTR,
   351  		},
   352  		{
   353  			name:  "bidi2",
   354  			lines: bidi2,
   355  			dir:   system.RTL,
   356  		},
   357  		{
   358  			name:  "bidi_wide",
   359  			lines: bidiWide,
   360  			dir:   system.LTR,
   361  		},
   362  		{
   363  			name:  "bidi2_wide",
   364  			lines: bidi2Wide,
   365  			dir:   system.RTL,
   366  		},
   367  	} {
   368  		t.Run(tc.name, func(t *testing.T) {
   369  			// We expect:
   370  			// - Line dimensions to be populated.
   371  			// - Line direction to be populated.
   372  			// - Runs to be ordered from lowest runes first.
   373  			// - Runs to have widths matching the input.
   374  			// - Runs to have the same total number of glyphs/runes as the input.
   375  			runesSeen := Range{}
   376  			shaper := testShaper(ltrFace, rtlFace)
   377  			for i, input := range tc.lines {
   378  				seenRun := make([]bool, len(input))
   379  				inputLowestRuneOffset := math.MaxInt
   380  				totalInputGlyphs := 0
   381  				totalInputRunes := 0
   382  				for _, run := range input {
   383  					if run.Runes.Offset < inputLowestRuneOffset {
   384  						inputLowestRuneOffset = run.Runes.Offset
   385  					}
   386  					totalInputGlyphs += len(run.Glyphs)
   387  					totalInputRunes += run.Runes.Count
   388  				}
   389  				output := toLine(shaper.faceToIndex, input, tc.dir)
   390  				if output.direction != tc.dir {
   391  					t.Errorf("line %d: expected direction %v, got %v", i, tc.dir, output.direction)
   392  				}
   393  				totalRunWidth := fixed.I(0)
   394  				totalLineGlyphs := 0
   395  				totalLineRunes := 0
   396  				for k, run := range output.runs {
   397  					seenRun[run.VisualPosition] = true
   398  					if output.visualOrder[run.VisualPosition] != k {
   399  						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)
   400  					}
   401  					if run.Runes.Offset != totalLineRunes {
   402  						t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, totalLineRunes, run.Runes.Offset)
   403  					}
   404  					runGlyphCount := len(run.Glyphs)
   405  					if inputGlyphs := len(input[k].Glyphs); runGlyphCount != inputGlyphs {
   406  						t.Errorf("line %d, run %d: expected %d glyphs, found %d", i, k, inputGlyphs, runGlyphCount)
   407  					}
   408  					runRuneCount := 0
   409  					currentCluster := -1
   410  					for _, g := range run.Glyphs {
   411  						if g.clusterIndex != currentCluster {
   412  							runRuneCount += g.runeCount
   413  							currentCluster = g.clusterIndex
   414  						}
   415  					}
   416  					if run.Runes.Count != runRuneCount {
   417  						t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
   418  					}
   419  					runesSeen.Count += run.Runes.Count
   420  					totalRunWidth += fixedAbs(run.Advance)
   421  					totalLineGlyphs += len(run.Glyphs)
   422  					totalLineRunes += run.Runes.Count
   423  				}
   424  				if output.runeCount != totalInputRunes {
   425  					t.Errorf("line %d: input had %d runes, only counted %d", i, totalInputRunes, output.runeCount)
   426  				}
   427  				if totalLineGlyphs != totalInputGlyphs {
   428  					t.Errorf("line %d: input had %d glyphs, only counted %d", i, totalInputRunes, totalLineGlyphs)
   429  				}
   430  				if totalRunWidth != output.width {
   431  					t.Errorf("line %d: expected width %d, got %d", i, totalRunWidth, output.width)
   432  				}
   433  				for runIndex, seen := range seenRun {
   434  					if !seen {
   435  						t.Errorf("line %d, run %d missing from runs VisualPosition fields", i, runIndex)
   436  					}
   437  				}
   438  			}
   439  			lastLine := tc.lines[len(tc.lines)-1]
   440  			maxRunes := 0
   441  			for _, run := range lastLine {
   442  				if run.Runes.Count+run.Runes.Offset > maxRunes {
   443  					maxRunes = run.Runes.Count + run.Runes.Offset
   444  				}
   445  			}
   446  			if runesSeen.Count != maxRunes {
   447  				t.Errorf("input covered %d runes, output only covers %d", maxRunes, runesSeen.Count)
   448  			}
   449  		})
   450  	}
   451  }
   452  
   453  func TestComputeVisualOrder(t *testing.T) {
   454  	type testcase struct {
   455  		name                string
   456  		input               line
   457  		expectedVisualOrder []int
   458  	}
   459  	for _, tc := range []testcase{
   460  		{
   461  			name: "ltr",
   462  			input: line{
   463  				direction: system.LTR,
   464  				runs: []runLayout{
   465  					{Direction: system.LTR},
   466  					{Direction: system.LTR},
   467  					{Direction: system.LTR},
   468  				},
   469  			},
   470  			expectedVisualOrder: []int{0, 1, 2},
   471  		},
   472  		{
   473  			name: "rtl",
   474  			input: line{
   475  				direction: system.RTL,
   476  				runs: []runLayout{
   477  					{Direction: system.RTL},
   478  					{Direction: system.RTL},
   479  					{Direction: system.RTL},
   480  				},
   481  			},
   482  			expectedVisualOrder: []int{2, 1, 0},
   483  		},
   484  		{
   485  			name: "bidi-ltr",
   486  			input: line{
   487  				direction: system.LTR,
   488  				runs: []runLayout{
   489  					{Direction: system.LTR},
   490  					{Direction: system.RTL},
   491  					{Direction: system.RTL},
   492  					{Direction: system.RTL},
   493  					{Direction: system.LTR},
   494  				},
   495  			},
   496  			expectedVisualOrder: []int{0, 3, 2, 1, 4},
   497  		},
   498  		{
   499  			name: "bidi-ltr-complex",
   500  			input: line{
   501  				direction: system.LTR,
   502  				runs: []runLayout{
   503  					{Direction: system.RTL},
   504  					{Direction: system.RTL},
   505  					{Direction: system.LTR},
   506  					{Direction: system.RTL},
   507  					{Direction: system.RTL},
   508  					{Direction: system.LTR},
   509  					{Direction: system.RTL},
   510  					{Direction: system.RTL},
   511  					{Direction: system.LTR},
   512  					{Direction: system.RTL},
   513  					{Direction: system.RTL},
   514  				},
   515  			},
   516  			expectedVisualOrder: []int{1, 0, 2, 4, 3, 5, 7, 6, 8, 10, 9},
   517  		},
   518  		{
   519  			name: "bidi-rtl",
   520  			input: line{
   521  				direction: system.RTL,
   522  				runs: []runLayout{
   523  					{Direction: system.RTL},
   524  					{Direction: system.LTR},
   525  					{Direction: system.LTR},
   526  					{Direction: system.LTR},
   527  					{Direction: system.RTL},
   528  				},
   529  			},
   530  			expectedVisualOrder: []int{4, 1, 2, 3, 0},
   531  		},
   532  		{
   533  			name: "bidi-rtl-complex",
   534  			input: line{
   535  				direction: system.RTL,
   536  				runs: []runLayout{
   537  					{Direction: system.LTR},
   538  					{Direction: system.LTR},
   539  					{Direction: system.RTL},
   540  					{Direction: system.LTR},
   541  					{Direction: system.LTR},
   542  					{Direction: system.RTL},
   543  					{Direction: system.LTR},
   544  					{Direction: system.LTR},
   545  					{Direction: system.RTL},
   546  					{Direction: system.LTR},
   547  					{Direction: system.LTR},
   548  				},
   549  			},
   550  			expectedVisualOrder: []int{9, 10, 8, 6, 7, 5, 3, 4, 2, 0, 1},
   551  		},
   552  	} {
   553  		t.Run(tc.name, func(t *testing.T) {
   554  			computeVisualOrder(&tc.input)
   555  			if !reflect.DeepEqual(tc.input.visualOrder, tc.expectedVisualOrder) {
   556  				t.Errorf("expected visual order %v, got %v", tc.expectedVisualOrder, tc.input.visualOrder)
   557  			}
   558  			for i, visualIndex := range tc.input.visualOrder {
   559  				if pos := tc.input.runs[visualIndex].VisualPosition; pos != i {
   560  					t.Errorf("line.VisualOrder[%d]=%d, but line.Runs[%d].VisualPosition=%d", i, visualIndex, visualIndex, pos)
   561  				}
   562  			}
   563  		})
   564  	}
   565  }
   566  
   567  func FuzzLayout(f *testing.F) {
   568  	ltrFace, _ := opentype.Parse(goregular.TTF)
   569  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   570  	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, false, uint8(10), uint16(200))
   571  
   572  	shaper := testShaper(ltrFace, rtlFace)
   573  	f.Fuzz(func(t *testing.T, txt string, rtl bool, truncate bool, fontSize uint8, width uint16) {
   574  		locale := system.Locale{
   575  			Direction: system.LTR,
   576  		}
   577  		if rtl {
   578  			locale.Direction = system.RTL
   579  		}
   580  		if fontSize < 1 {
   581  			fontSize = 1
   582  		}
   583  		maxLines := 0
   584  		if truncate {
   585  			maxLines = 1
   586  		}
   587  		lines := shaper.LayoutRunes(Parameters{
   588  			PxPerEm:  fixed.I(int(fontSize)),
   589  			MaxWidth: int(width),
   590  			MaxLines: maxLines,
   591  			Locale:   locale,
   592  		}, []rune(txt))
   593  		validateLines(t, lines.lines, len([]rune(txt)))
   594  	})
   595  }
   596  
   597  func validateLines(t *testing.T, lines []line, expectedRuneCount int) {
   598  	t.Helper()
   599  	runesSeen := 0
   600  	for i, line := range lines {
   601  		totalRunWidth := fixed.I(0)
   602  		totalLineGlyphs := 0
   603  		lineRunesSeen := 0
   604  		for k, run := range line.runs {
   605  			if line.visualOrder[run.VisualPosition] != k {
   606  				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)
   607  			}
   608  			if run.Runes.Offset != lineRunesSeen {
   609  				t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, lineRunesSeen, run.Runes.Offset)
   610  			}
   611  			runRuneCount := 0
   612  			currentCluster := -1
   613  			for _, g := range run.Glyphs {
   614  				if g.clusterIndex != currentCluster {
   615  					runRuneCount += g.runeCount
   616  					currentCluster = g.clusterIndex
   617  				}
   618  			}
   619  			if run.Runes.Count != runRuneCount {
   620  				t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
   621  			}
   622  			lineRunesSeen += run.Runes.Count
   623  			totalRunWidth += fixedAbs(run.Advance)
   624  			totalLineGlyphs += len(run.Glyphs)
   625  		}
   626  		if totalRunWidth != line.width {
   627  			t.Errorf("line %d: expected width %d, got %d", i, line.width, totalRunWidth)
   628  		}
   629  		runesSeen += lineRunesSeen
   630  	}
   631  	if runesSeen != expectedRuneCount {
   632  		t.Errorf("input covered %d runes, output only covers %d", expectedRuneCount, runesSeen)
   633  	}
   634  }
   635  
   636  // TestTextAppend ensures that appending two texts together correctly updates the new lines'
   637  // y offsets.
   638  func TestTextAppend(t *testing.T) {
   639  	ltrFace, _ := opentype.Parse(goregular.TTF)
   640  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   641  
   642  	shaper := testShaper(ltrFace, rtlFace)
   643  
   644  	text1 := shaper.LayoutString(Parameters{
   645  		PxPerEm:  fixed.I(14),
   646  		MaxWidth: 200,
   647  		Locale:   english,
   648  	}, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd  لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
   649  	text2 := shaper.LayoutString(Parameters{
   650  		PxPerEm:  fixed.I(14),
   651  		MaxWidth: 200,
   652  		Locale:   english,
   653  	}, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd  لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
   654  
   655  	text1.append(text2)
   656  	curY := math.MinInt
   657  	for lineNum, line := range text1.lines {
   658  		yOff := line.yOffset
   659  		if yOff <= curY {
   660  			t.Errorf("lines[%d] has y offset %d, <= to previous %d", lineNum, yOff, curY)
   661  		}
   662  		curY = yOff
   663  	}
   664  }
   665  
   666  func TestGlyphIDPacking(t *testing.T) {
   667  	const maxPPem = fixed.Int26_6((1 << sizebits) - 1)
   668  	type testcase struct {
   669  		name      string
   670  		ppem      fixed.Int26_6
   671  		faceIndex int
   672  		gid       font.GID
   673  		expected  GlyphID
   674  	}
   675  	for _, tc := range []testcase{
   676  		{
   677  			name: "zero value",
   678  		},
   679  		{
   680  			name:      "10 ppem faceIdx 1 GID 5",
   681  			ppem:      fixed.I(10),
   682  			faceIndex: 1,
   683  			gid:       5,
   684  			expected:  284223755780101,
   685  		},
   686  		{
   687  			name:      maxPPem.String() + " ppem faceIdx " + strconv.Itoa(math.MaxUint16) + " GID " + fmt.Sprintf("%d", int64(math.MaxUint32)),
   688  			ppem:      maxPPem,
   689  			faceIndex: math.MaxUint16,
   690  			gid:       math.MaxUint32,
   691  			expected:  18446744073709551615,
   692  		},
   693  	} {
   694  		t.Run(tc.name, func(t *testing.T) {
   695  			actual := newGlyphID(tc.ppem, tc.faceIndex, tc.gid)
   696  			if actual != tc.expected {
   697  				t.Errorf("expected %d, got %d", tc.expected, actual)
   698  			}
   699  			actualPPEM, actualFaceIdx, actualGID := splitGlyphID(actual)
   700  			if actualPPEM != tc.ppem {
   701  				t.Errorf("expected ppem %d, got %d", tc.ppem, actualPPEM)
   702  			}
   703  			if actualFaceIdx != tc.faceIndex {
   704  				t.Errorf("expected faceIdx %d, got %d", tc.faceIndex, actualFaceIdx)
   705  			}
   706  			if actualGID != tc.gid {
   707  				t.Errorf("expected gid %d, got %d", tc.gid, actualGID)
   708  			}
   709  		})
   710  	}
   711  }