diff --git a/text/gotext.go b/text/gotext.go index 3b37c6d1..066e18f5 100644 --- a/text/gotext.go +++ b/text/gotext.go @@ -95,6 +95,55 @@ type line struct { yOffset int } +// insertTrailingSyntheticNewline adds a synthetic newline to the final logical run of the line +// with the given shaping cluster index. +func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) { + // If there was a newline at the end of this paragraph, insert a synthetic glyph representing it. + finalContentRun := len(l.runs) - 1 + // If there was a trailing newline update the rune counts to include + // it on the last line of the paragraph. + l.runeCount += 1 + l.runs[finalContentRun].Runes.Count += 1 + + syntheticGlyph := glyph{ + id: 0, + clusterIndex: newLineClusterIdx, + 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 l.runs[finalContentRun].Direction.Progression() == system.FromOrigin { + l.runs[finalContentRun].Glyphs = append(l.runs[finalContentRun].Glyphs, syntheticGlyph) + } else { + // Ensure capacity. + l.runs[finalContentRun].Glyphs = append(l.runs[finalContentRun].Glyphs, glyph{}) + copy(l.runs[finalContentRun].Glyphs[1:], l.runs[finalContentRun].Glyphs) + l.runs[finalContentRun].Glyphs[0] = syntheticGlyph + } +} + +func (l *line) setTruncatedCount(truncatedCount int) { + // If we've truncated the text with a truncator, adjust the rune counts within the + // truncator to make it represent the truncated text. + finalRunIdx := len(l.runs) - 1 + l.runs[finalRunIdx].truncator = true + finalGlyphIdx := len(l.runs[finalRunIdx].Glyphs) - 1 + // The run represents all of the truncated text. + l.runs[finalRunIdx].Runes.Count = truncatedCount + // Only the final glyph represents any runes, and it represents all truncated text. + for i := range l.runs[finalRunIdx].Glyphs { + if i == finalGlyphIdx { + l.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = truncatedCount + } else { + l.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = 0 + } + } +} + // 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. @@ -530,16 +579,21 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document { if hasNewline { txt = txt[:len(txt)-1] } + if params.MaxLines != 0 && hasNewline { + // If we might end up truncating a trailing newline, we must insert the truncator symbol + // on the final line (if we hit the limit). + params.forceTruncate = true + } ls, truncated = s.shapeAndWrapText(params, replaceControlCharacters(txt)) - didTruncate := truncated > 0 || (params.forceTruncate && params.MaxLines == len(ls)) - - if didTruncate && hasNewline { - // We've truncated the newline, since it was at the end and we've truncated some amount of runes - // before it. + hasTruncator := truncated > 0 || (params.forceTruncate && params.MaxLines == len(ls)) + if hasTruncator && hasNewline { + // We have a truncator at the end of the line, so the newline is logically + // truncated as well. truncated++ hasNewline = false } + // Convert to Lines. textLines := make([]line, len(ls)) maxHeight := fixed.Int26_6(0) @@ -548,49 +602,12 @@ func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document { if otLine.lineHeight > maxHeight { maxHeight = otLine.lineHeight } - isFinalLine := i == len(ls)-1 - if isFinalLine && 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, + if isFinalLine := i == len(ls)-1; isFinalLine { + if hasNewline { + otLine.insertTrailingSyntheticNewline(len(txt)) } - // 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 - } - } - if isFinalLine && didTruncate { - // If we've truncated the text with a truncator, adjust the rune counts within the - // truncator to make it represent the truncated text. - finalRunIdx := len(otLine.runs) - 1 - otLine.runs[finalRunIdx].truncator = true - finalGlyphIdx := len(otLine.runs[finalRunIdx].Glyphs) - 1 - // The run represents all of the truncated text. - otLine.runs[finalRunIdx].Runes.Count = truncated - // Only the final glyph represents any runes, and it represents all truncated text. - for i := range otLine.runs[finalRunIdx].Glyphs { - if i == finalGlyphIdx { - otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = truncated - } else { - otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = 0 - } + if hasTruncator { + otLine.setTruncatedCount(truncated) } } textLines[i] = otLine diff --git a/text/shaper_test.go b/text/shaper_test.go index b2d894f7..9a85efe7 100644 --- a/text/shaper_test.go +++ b/text/shaper_test.go @@ -154,9 +154,11 @@ func TestWrappingForcedTruncation(t *testing.T) { // consistently and does not create spurious lines of text. func TestShapingNewlineHandling(t *testing.T) { type testcase struct { - textInput string - expectedLines int - expectedGlyphs int + textInput string + expectedLines int + expectedGlyphs int + maxLines int + expectedTruncated int } for _, tc := range []testcase{ {textInput: "a\n", expectedLines: 1, expectedGlyphs: 3}, @@ -165,21 +167,41 @@ func TestShapingNewlineHandling(t *testing.T) { {textInput: "\n", expectedLines: 1, expectedGlyphs: 2}, {textInput: "\n\n", expectedLines: 2, expectedGlyphs: 3}, {textInput: "\n\n\n", expectedLines: 3, expectedGlyphs: 4}, + {textInput: "\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 1}, + {textInput: "\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 2}, + {textInput: "\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 3}, + {textInput: "a\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 1}, + {textInput: "a\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 2}, + {textInput: "a\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 3}, + {textInput: "\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 2}, + {textInput: "\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 1}, + {textInput: "\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 2}, + {textInput: "a\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 3}, + {textInput: "a\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 1}, + {textInput: "a\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 2}, } { - t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) { + t.Run(fmt.Sprintf("%q-maxLines%d", tc.textInput, tc.maxLines), func(t *testing.T) { ltrFace, _ := opentype.Parse(goregular.TTF) collection := []FontFace{{Face: ltrFace}} cache := NewShaper(NoSystemFonts(), WithCollection(collection)) checkGlyphs := func() { glyphs := []Glyph{} runes := 0 + truncated := 0 for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() { glyphs = append(glyphs, g) - runes += g.Runes + if g.Flags&FlagTruncator == 0 { + runes += g.Runes + } else { + truncated += g.Runes + } } - if expected := len([]rune(tc.textInput)); expected != runes { + if expected := len([]rune(tc.textInput)) - tc.expectedTruncated; expected != runes { t.Errorf("expected %d runes, got %d", expected, runes) } + if truncated != tc.expectedTruncated { + t.Errorf("expected %d truncated runes, got %d", tc.expectedTruncated, truncated) + } if len(glyphs) != tc.expectedGlyphs { t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs)) } @@ -207,29 +229,27 @@ func TestShapingNewlineHandling(t *testing.T) { t.Errorf("expected paragraph start glyph to have cursor y") } } - if count := strings.Count(tc.textInput, "\n"); found != count { + if count := strings.Count(tc.textInput, "\n"); found != count && tc.maxLines == 0 { t.Errorf("expected %d paragraph breaks, found %d", count, found) + } else if tc.maxLines > 0 && found > tc.maxLines { + t.Errorf("expected %d paragraph breaks due to truncation, found %d", tc.maxLines, found) } } - cache.LayoutString(Parameters{ + params := Parameters{ Alignment: Middle, PxPerEm: fixed.I(10), MinWidth: 200, MaxWidth: 200, Locale: english, - }, tc.textInput) + MaxLines: tc.maxLines, + } + cache.LayoutString(params, tc.textInput) if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines { t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount) } checkGlyphs() - cache.Layout(Parameters{ - Alignment: Middle, - PxPerEm: fixed.I(10), - MinWidth: 200, - MaxWidth: 200, - Locale: english, - }, strings.NewReader(tc.textInput)) + cache.Layout(params, strings.NewReader(tc.textInput)) if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines { t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount) }