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

     1  package text
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  	"testing"
     7  
     8  	nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
     9  	"github.com/utopiagio/gio/font"
    10  	"github.com/utopiagio/gio/font/gofont"
    11  	"github.com/utopiagio/gio/font/opentype"
    12  	"github.com/utopiagio/gio/io/system"
    13  	"golang.org/x/exp/slices"
    14  	"golang.org/x/image/font/gofont/goregular"
    15  	"golang.org/x/image/math/fixed"
    16  )
    17  
    18  // TestWrappingTruncation checks that the line wrapper's truncation features
    19  // behave as expected.
    20  func TestWrappingTruncation(t *testing.T) {
    21  	// Use a test string containing multiple newlines to ensure that they are shaped
    22  	// as separate paragraphs.
    23  	textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n"
    24  	ltrFace, _ := opentype.Parse(goregular.TTF)
    25  	collection := []FontFace{{Face: ltrFace}}
    26  	cache := NewShaper(NoSystemFonts(), WithCollection(collection))
    27  	cache.LayoutString(Parameters{
    28  		Alignment: Middle,
    29  		PxPerEm:   fixed.I(10),
    30  		MinWidth:  200,
    31  		MaxWidth:  200,
    32  		Locale:    english,
    33  	}, textInput)
    34  	untruncatedCount := len(cache.txt.lines)
    35  
    36  	for i := untruncatedCount + 1; i > 0; i-- {
    37  		t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
    38  			cache.LayoutString(Parameters{
    39  				Alignment: Middle,
    40  				PxPerEm:   fixed.I(10),
    41  				MaxLines:  i,
    42  				MinWidth:  200,
    43  				MaxWidth:  200,
    44  				Locale:    english,
    45  			}, textInput)
    46  			lineCount := 0
    47  			lastGlyphWasLineBreak := false
    48  			glyphs := []Glyph{}
    49  			untruncatedRunes := 0
    50  			truncatedRunes := 0
    51  			for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
    52  				glyphs = append(glyphs, g)
    53  				if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
    54  					truncatedRunes += int(g.Runes)
    55  				} else {
    56  					untruncatedRunes += int(g.Runes)
    57  				}
    58  				if g.Flags&FlagLineBreak != 0 {
    59  					lineCount++
    60  					lastGlyphWasLineBreak = true
    61  				} else {
    62  					lastGlyphWasLineBreak = false
    63  				}
    64  			}
    65  			if lastGlyphWasLineBreak && truncatedRunes == 0 {
    66  				// There was no actual line of text following this break.
    67  				lineCount--
    68  			}
    69  			if i <= untruncatedCount {
    70  				if lineCount != i {
    71  					t.Errorf("expected %d lines, got %d", i, lineCount)
    72  				}
    73  			} else if i > untruncatedCount {
    74  				if lineCount != untruncatedCount {
    75  					t.Errorf("expected %d lines, got %d", untruncatedCount, lineCount)
    76  				}
    77  			}
    78  			if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected {
    79  				t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes)
    80  			}
    81  		})
    82  	}
    83  }
    84  
    85  // TestWrappingForcedTruncation checks that the line wrapper's truncation features
    86  // activate correctly on multi-paragraph text when later paragraphs are truncated.
    87  func TestWrappingForcedTruncation(t *testing.T) {
    88  	// Use a test string containing multiple newlines to ensure that they are shaped
    89  	// as separate paragraphs.
    90  	textInput := "Lorem ipsum\ndolor sit\namet"
    91  	ltrFace, _ := opentype.Parse(goregular.TTF)
    92  	collection := []FontFace{{Face: ltrFace}}
    93  	cache := NewShaper(NoSystemFonts(), WithCollection(collection))
    94  	cache.LayoutString(Parameters{
    95  		Alignment: Middle,
    96  		PxPerEm:   fixed.I(10),
    97  		MinWidth:  200,
    98  		MaxWidth:  200,
    99  		Locale:    english,
   100  	}, textInput)
   101  	untruncatedCount := len(cache.txt.lines)
   102  
   103  	for i := untruncatedCount + 1; i > 0; i-- {
   104  		t.Run(fmt.Sprintf("truncated to %d/%d lines", i, untruncatedCount), func(t *testing.T) {
   105  			cache.LayoutString(Parameters{
   106  				Alignment: Middle,
   107  				PxPerEm:   fixed.I(10),
   108  				MaxLines:  i,
   109  				MinWidth:  200,
   110  				MaxWidth:  200,
   111  				Locale:    english,
   112  			}, textInput)
   113  			lineCount := 0
   114  			glyphs := []Glyph{}
   115  			untruncatedRunes := 0
   116  			truncatedRunes := 0
   117  			for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
   118  				glyphs = append(glyphs, g)
   119  				if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
   120  					truncatedRunes += int(g.Runes)
   121  				} else {
   122  					untruncatedRunes += int(g.Runes)
   123  				}
   124  				if g.Flags&FlagLineBreak != 0 {
   125  					lineCount++
   126  				}
   127  			}
   128  			expectedTruncated := false
   129  			expectedLines := 0
   130  			if i < untruncatedCount {
   131  				expectedLines = i
   132  				expectedTruncated = true
   133  			} else if i == untruncatedCount {
   134  				expectedLines = i
   135  				expectedTruncated = false
   136  			} else if i > untruncatedCount {
   137  				expectedLines = untruncatedCount
   138  				expectedTruncated = false
   139  			}
   140  			if lineCount != expectedLines {
   141  				t.Errorf("expected %d lines, got %d", expectedLines, lineCount)
   142  			}
   143  			if truncatedRunes > 0 != expectedTruncated {
   144  				t.Errorf("expected expectedTruncated=%v, truncatedRunes=%d", expectedTruncated, truncatedRunes)
   145  			}
   146  			if expected := len([]rune(textInput)); truncatedRunes+untruncatedRunes != expected {
   147  				t.Errorf("expected %d total runes, got %d (%d truncated)", expected, truncatedRunes+untruncatedRunes, truncatedRunes)
   148  			}
   149  		})
   150  	}
   151  }
   152  
   153  // TestShapingNewlineHandling checks that the shaper's newline splitting behaves
   154  // consistently and does not create spurious lines of text.
   155  func TestShapingNewlineHandling(t *testing.T) {
   156  	type testcase struct {
   157  		textInput         string
   158  		expectedLines     int
   159  		expectedGlyphs    int
   160  		maxLines          int
   161  		expectedTruncated int
   162  	}
   163  	for _, tc := range []testcase{
   164  		{textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
   165  		{textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
   166  		{textInput: "", expectedLines: 1, expectedGlyphs: 1},
   167  		{textInput: "\n", expectedLines: 1, expectedGlyphs: 2},
   168  		{textInput: "\n\n", expectedLines: 2, expectedGlyphs: 3},
   169  		{textInput: "\n\n\n", expectedLines: 3, expectedGlyphs: 4},
   170  		{textInput: "\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 1},
   171  		{textInput: "\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 2},
   172  		{textInput: "\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 3},
   173  		{textInput: "a\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 1},
   174  		{textInput: "a\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 2},
   175  		{textInput: "a\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 3},
   176  		{textInput: "\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 2},
   177  		{textInput: "\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 1},
   178  		{textInput: "\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 2},
   179  		{textInput: "a\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 3},
   180  		{textInput: "a\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 1},
   181  		{textInput: "a\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 2},
   182  	} {
   183  		t.Run(fmt.Sprintf("%q-maxLines%d", tc.textInput, tc.maxLines), func(t *testing.T) {
   184  			ltrFace, _ := opentype.Parse(goregular.TTF)
   185  			collection := []FontFace{{Face: ltrFace}}
   186  			cache := NewShaper(NoSystemFonts(), WithCollection(collection))
   187  			checkGlyphs := func() {
   188  				glyphs := []Glyph{}
   189  				runes := 0
   190  				truncated := 0
   191  				for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
   192  					glyphs = append(glyphs, g)
   193  					if g.Flags&FlagTruncator == 0 {
   194  						runes += int(g.Runes)
   195  					} else {
   196  						truncated += int(g.Runes)
   197  					}
   198  				}
   199  				if expected := len([]rune(tc.textInput)) - tc.expectedTruncated; expected != runes {
   200  					t.Errorf("expected %d runes, got %d", expected, runes)
   201  				}
   202  				if truncated != tc.expectedTruncated {
   203  					t.Errorf("expected %d truncated runes, got %d", tc.expectedTruncated, truncated)
   204  				}
   205  				if len(glyphs) != tc.expectedGlyphs {
   206  					t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs))
   207  				}
   208  				findBreak := func(g Glyph) bool {
   209  					return g.Flags&FlagParagraphBreak != 0
   210  				}
   211  				found := 0
   212  				for idx := slices.IndexFunc(glyphs, findBreak); idx != -1; idx = slices.IndexFunc(glyphs, findBreak) {
   213  					found++
   214  					breakGlyph := glyphs[idx]
   215  					startGlyph := glyphs[idx+1]
   216  					glyphs = glyphs[idx+1:]
   217  					if flags := breakGlyph.Flags; flags&FlagParagraphBreak == 0 {
   218  						t.Errorf("expected newline glyph to have P flag, got %s", flags)
   219  					}
   220  					if flags := startGlyph.Flags; flags&FlagParagraphStart == 0 {
   221  						t.Errorf("expected newline glyph to have S flag, got %s", flags)
   222  					}
   223  					breakX, breakY := breakGlyph.X, breakGlyph.Y
   224  					startX, startY := startGlyph.X, startGlyph.Y
   225  					if breakX == startX && idx != 0 {
   226  						t.Errorf("expected paragraph start glyph to have cursor x, got %v", startX)
   227  					}
   228  					if breakY == startY {
   229  						t.Errorf("expected paragraph start glyph to have cursor y")
   230  					}
   231  				}
   232  				if count := strings.Count(tc.textInput, "\n"); found != count && tc.maxLines == 0 {
   233  					t.Errorf("expected %d paragraph breaks, found %d", count, found)
   234  				} else if tc.maxLines > 0 && found > tc.maxLines {
   235  					t.Errorf("expected %d paragraph breaks due to truncation, found %d", tc.maxLines, found)
   236  				}
   237  			}
   238  			params := Parameters{
   239  				Alignment: Middle,
   240  				PxPerEm:   fixed.I(10),
   241  				MinWidth:  200,
   242  				MaxWidth:  200,
   243  				Locale:    english,
   244  				MaxLines:  tc.maxLines,
   245  			}
   246  			cache.LayoutString(params, tc.textInput)
   247  			if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
   248  				t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount)
   249  			}
   250  			checkGlyphs()
   251  
   252  			cache.Layout(params, strings.NewReader(tc.textInput))
   253  			if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
   254  				t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount)
   255  			}
   256  			checkGlyphs()
   257  		})
   258  	}
   259  }
   260  
   261  // TestCacheEmptyString ensures that shaping the empty string returns a
   262  // single synthetic glyph with ascent/descent info.
   263  func TestCacheEmptyString(t *testing.T) {
   264  	ltrFace, _ := opentype.Parse(goregular.TTF)
   265  	collection := []FontFace{{Face: ltrFace}}
   266  	cache := NewShaper(NoSystemFonts(), WithCollection(collection))
   267  	cache.LayoutString(Parameters{
   268  		Alignment: Middle,
   269  		PxPerEm:   fixed.I(10),
   270  		MinWidth:  200,
   271  		MaxWidth:  200,
   272  		Locale:    english,
   273  	}, "")
   274  	glyphs := make([]Glyph, 0, 1)
   275  	for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
   276  		glyphs = append(glyphs, g)
   277  	}
   278  	if len(glyphs) != 1 {
   279  		t.Errorf("expected %d glyphs, got %d", 1, len(glyphs))
   280  	}
   281  	glyph := glyphs[0]
   282  	checkFlag(t, true, FlagClusterBreak, glyph, 0)
   283  	checkFlag(t, true, FlagRunBreak, glyph, 0)
   284  	checkFlag(t, true, FlagLineBreak, glyph, 0)
   285  	checkFlag(t, false, FlagParagraphBreak, glyph, 0)
   286  	if glyph.Ascent == 0 {
   287  		t.Errorf("expected non-zero ascent")
   288  	}
   289  	if glyph.Descent == 0 {
   290  		t.Errorf("expected non-zero descent")
   291  	}
   292  	if glyph.Y == 0 {
   293  		t.Errorf("expected non-zero y offset")
   294  	}
   295  	if glyph.X == 0 {
   296  		t.Errorf("expected non-zero x offset")
   297  	}
   298  }
   299  
   300  // TestCacheAlignment ensures that shaping with different alignments or dominant
   301  // text directions results in different X offsets.
   302  func TestCacheAlignment(t *testing.T) {
   303  	ltrFace, _ := opentype.Parse(goregular.TTF)
   304  	collection := []FontFace{{Face: ltrFace}}
   305  	cache := NewShaper(NoSystemFonts(), WithCollection(collection))
   306  	params := Parameters{
   307  		Alignment: Start,
   308  		PxPerEm:   fixed.I(10),
   309  		MinWidth:  200,
   310  		MaxWidth:  200,
   311  		Locale:    english,
   312  	}
   313  	cache.LayoutString(params, "A")
   314  	glyph, _ := cache.NextGlyph()
   315  	startX := glyph.X
   316  	params.Alignment = Middle
   317  	cache.LayoutString(params, "A")
   318  	glyph, _ = cache.NextGlyph()
   319  	middleX := glyph.X
   320  	params.Alignment = End
   321  	cache.LayoutString(params, "A")
   322  	glyph, _ = cache.NextGlyph()
   323  	endX := glyph.X
   324  	if startX == middleX || startX == endX || endX == middleX {
   325  		t.Errorf("[LTR] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", startX, middleX, endX)
   326  	}
   327  	params.Locale = arabic
   328  	params.Alignment = Start
   329  	cache.LayoutString(params, "A")
   330  	glyph, _ = cache.NextGlyph()
   331  	rtlStartX := glyph.X
   332  	params.Alignment = Middle
   333  	cache.LayoutString(params, "A")
   334  	glyph, _ = cache.NextGlyph()
   335  	rtlMiddleX := glyph.X
   336  	params.Alignment = End
   337  	cache.LayoutString(params, "A")
   338  	glyph, _ = cache.NextGlyph()
   339  	rtlEndX := glyph.X
   340  	if rtlStartX == rtlMiddleX || rtlStartX == rtlEndX || rtlEndX == rtlMiddleX {
   341  		t.Errorf("[RTL] shaping with with different alignments should not produce the same X, start %d, middle %d, end %d", rtlStartX, rtlMiddleX, rtlEndX)
   342  	}
   343  	if startX == rtlStartX || endX == rtlEndX {
   344  		t.Errorf("shaping with with different dominant text directions and the same alignment should not produce the same X unless it's middle-aligned")
   345  	}
   346  }
   347  
   348  func TestCacheGlyphConverstion(t *testing.T) {
   349  	ltrFace, _ := opentype.Parse(goregular.TTF)
   350  	rtlFace, _ := opentype.Parse(nsareg.TTF)
   351  	collection := []FontFace{{Face: ltrFace}, {Face: rtlFace}}
   352  	type testcase struct {
   353  		name     string
   354  		text     string
   355  		locale   system.Locale
   356  		expected []Glyph
   357  	}
   358  	for _, tc := range []testcase{
   359  		{
   360  			name:   "bidi ltr",
   361  			text:   "The quick سماء שלום لا fox تمط שלום\nغير the\nlazy dog.",
   362  			locale: english,
   363  		},
   364  		{
   365  			name:   "bidi rtl",
   366  			text:   "الحب سماء brown привет fox تمط jumps\nпривет over\nغير الأحلام.",
   367  			locale: arabic,
   368  		},
   369  	} {
   370  		t.Run(tc.name, func(t *testing.T) {
   371  			cache := NewShaper(NoSystemFonts(), WithCollection(collection))
   372  			cache.LayoutString(Parameters{
   373  				PxPerEm:  fixed.I(10),
   374  				MaxWidth: 200,
   375  				Locale:   tc.locale,
   376  			}, tc.text)
   377  			doc := cache.txt
   378  			glyphs := make([]Glyph, 0, len(tc.expected))
   379  			for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
   380  				glyphs = append(glyphs, g)
   381  			}
   382  			glyphCursor := 0
   383  			for _, line := range doc.lines {
   384  				for runIdx, run := range line.runs {
   385  					lastRun := runIdx == len(line.runs)-1
   386  					start := 0
   387  					end := len(run.Glyphs) - 1
   388  					inc := 1
   389  					towardOrigin := false
   390  					if run.Direction.Progression() == system.TowardOrigin {
   391  						start = len(run.Glyphs) - 1
   392  						end = 0
   393  						inc = -1
   394  						towardOrigin = true
   395  					}
   396  					for glyphIdx := start; ; glyphIdx += inc {
   397  						endOfRun := glyphIdx == end
   398  						glyph := run.Glyphs[glyphIdx]
   399  						endOfCluster := glyphIdx == end || run.Glyphs[glyphIdx+inc].clusterIndex != glyph.clusterIndex
   400  
   401  						actual := glyphs[glyphCursor]
   402  						if actual.ID != glyph.id {
   403  							t.Errorf("glyphs[%d] expected id %d, got id %d", glyphCursor, glyph.id, actual.ID)
   404  						}
   405  						// Synthetic glyphs should only ever show up at the end of lines.
   406  						endOfLine := lastRun && endOfRun
   407  						synthetic := glyph.glyphCount == 0 && endOfLine
   408  						checkFlag(t, endOfLine, FlagLineBreak, actual, glyphCursor)
   409  						checkFlag(t, endOfRun, FlagRunBreak, actual, glyphCursor)
   410  						checkFlag(t, towardOrigin, FlagTowardOrigin, actual, glyphCursor)
   411  						checkFlag(t, synthetic, FlagParagraphBreak, actual, glyphCursor)
   412  						checkFlag(t, endOfCluster, FlagClusterBreak, actual, glyphCursor)
   413  						glyphCursor++
   414  						if glyphIdx == end {
   415  							break
   416  						}
   417  					}
   418  				}
   419  			}
   420  
   421  			printLinePositioning(t, doc.lines, glyphs)
   422  		})
   423  	}
   424  }
   425  
   426  func checkFlag(t *testing.T, shouldHave bool, flag Flags, actual Glyph, glyphCursor int) {
   427  	t.Helper()
   428  	if shouldHave && actual.Flags&flag == 0 {
   429  		t.Errorf("glyphs[%d] should have %s set", glyphCursor, flag)
   430  	} else if !shouldHave && actual.Flags&flag != 0 {
   431  		t.Errorf("glyphs[%d] should not have %s set", glyphCursor, flag)
   432  	}
   433  }
   434  
   435  func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
   436  	t.Helper()
   437  	glyphCursor := 0
   438  	for i, line := range lines {
   439  		t.Logf("line %d, dir %s, width %d, visual %v, runeCount: %d", i, line.direction, line.width, line.visualOrder, line.runeCount)
   440  		for k, run := range line.runs {
   441  			t.Logf("run: %d, dir %s, width %d, runes {count: %d, offset: %d}", k, run.Direction, run.Advance, run.Runes.Count, run.Runes.Offset)
   442  			start := 0
   443  			end := len(run.Glyphs) - 1
   444  			inc := 1
   445  			if run.Direction.Progression() == system.TowardOrigin {
   446  				start = len(run.Glyphs) - 1
   447  				end = 0
   448  				inc = -1
   449  			}
   450  			for g := start; ; g += inc {
   451  				glyph := run.Glyphs[g]
   452  				if glyphCursor < len(glyphs) {
   453  					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)
   454  					t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
   455  				}
   456  				glyphCursor++
   457  				if g == end {
   458  					break
   459  				}
   460  			}
   461  		}
   462  	}
   463  }
   464  
   465  // TestShapeStringRuneAccounting tries shaping the same string/parameter combinations with both
   466  // shaping methods and ensures that the resulting glyph stream always has the right number of
   467  // runes accounted for.
   468  func TestShapeStringRuneAccounting(t *testing.T) {
   469  	type testcase struct {
   470  		name   string
   471  		input  string
   472  		params Parameters
   473  	}
   474  	type setup struct {
   475  		kind string
   476  		do   func(*Shaper, Parameters, string)
   477  	}
   478  	for _, tc := range []testcase{
   479  		{
   480  			name:  "simple truncated",
   481  			input: "abc",
   482  			params: Parameters{
   483  				PxPerEm:  fixed.Int26_6(16),
   484  				MaxWidth: 100,
   485  				MaxLines: 1,
   486  			},
   487  		},
   488  		{
   489  			name:  "simple",
   490  			input: "abc",
   491  			params: Parameters{
   492  				PxPerEm:  fixed.Int26_6(16),
   493  				MaxWidth: 100,
   494  			},
   495  		},
   496  		{
   497  			name:  "newline regression",
   498  			input: "\n",
   499  			params: Parameters{
   500  				Font:       font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
   501  				Alignment:  Start,
   502  				PxPerEm:    768,
   503  				MaxLines:   1,
   504  				Truncator:  "\u200b",
   505  				WrapPolicy: WrapHeuristically,
   506  				MaxWidth:   999929,
   507  			},
   508  		},
   509  		{
   510  			name:  "newline zero-width regression",
   511  			input: "\n",
   512  			params: Parameters{
   513  				Font:       font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
   514  				Alignment:  Start,
   515  				PxPerEm:    768,
   516  				MaxLines:   1,
   517  				Truncator:  "\u200b",
   518  				WrapPolicy: WrapHeuristically,
   519  				MaxWidth:   0,
   520  			},
   521  		},
   522  		{
   523  			name:  "double newline regression",
   524  			input: "\n\n",
   525  			params: Parameters{
   526  				Font:       font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
   527  				Alignment:  Start,
   528  				PxPerEm:    768,
   529  				MaxLines:   1,
   530  				Truncator:  "\u200b",
   531  				WrapPolicy: WrapHeuristically,
   532  				MaxWidth:   1000,
   533  			},
   534  		},
   535  		{
   536  			name:  "triple newline regression",
   537  			input: "\n\n\n",
   538  			params: Parameters{
   539  				Font:       font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
   540  				Alignment:  Start,
   541  				PxPerEm:    768,
   542  				MaxLines:   1,
   543  				Truncator:  "\u200b",
   544  				WrapPolicy: WrapHeuristically,
   545  				MaxWidth:   1000,
   546  			},
   547  		},
   548  	} {
   549  		t.Run(tc.name, func(t *testing.T) {
   550  			for _, setup := range []setup{
   551  				{
   552  					kind: "LayoutString",
   553  					do: func(shaper *Shaper, params Parameters, input string) {
   554  						shaper.LayoutString(params, input)
   555  					},
   556  				},
   557  				{
   558  					kind: "Layout",
   559  					do: func(shaper *Shaper, params Parameters, input string) {
   560  						shaper.Layout(params, strings.NewReader(input))
   561  					},
   562  				},
   563  			} {
   564  				t.Run(setup.kind, func(t *testing.T) {
   565  					shaper := NewShaper(NoSystemFonts(), WithCollection(gofont.Collection()))
   566  					setup.do(shaper, tc.params, tc.input)
   567  
   568  					glyphs := []Glyph{}
   569  					for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
   570  						glyphs = append(glyphs, g)
   571  					}
   572  					totalRunes := 0
   573  					for _, g := range glyphs {
   574  						totalRunes += int(g.Runes)
   575  					}
   576  					if inputRunes := len([]rune(tc.input)); totalRunes != inputRunes {
   577  						t.Errorf("input contained %d runes, but glyphs contained %d", inputRunes, totalRunes)
   578  					}
   579  				})
   580  			}
   581  		})
   582  	}
   583  }