From 8679f49fff9c071c0e1da78a53a965dd432a7c7f Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Tue, 22 Aug 2023 16:23:02 -0400 Subject: [PATCH] text: [API] be absolutely consistent about newline truncation This commit changes the shaper's behavior when truncating text. Previously, if the final line allowed by MaxLines ended with a newline, whether or not that newline was truncated depended upon whether we knew that there was more text after the current paragraph. However, this makes reasoning about what the shaper will do quite difficult. It seems better to be consistent. Now we will insert a truncator at the end of the final line if it has a trailing newline character, regardless of whether it ends the input text. Signed-off-by: Chris Waldon --- text/gotext.go | 111 +++++++++++++++++++++++++------------------- text/shaper_test.go | 52 ++++++++++++++------- 2 files changed, 100 insertions(+), 63 deletions(-) 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) }