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 }