diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 0278360ba5..d5908a2a23 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -117,28 +117,18 @@ namespace Avalonia.Controls { ClipToBoundsProperty.OverrideDefaultValue(true); - AffectsRender( - BackgroundProperty, ForegroundProperty, FontSizeProperty, - FontWeightProperty, FontStyleProperty, TextWrappingProperty, - TextTrimmingProperty, TextAlignmentProperty, FontFamilyProperty, - TextDecorationsProperty, TextProperty, PaddingProperty); - - AffectsMeasure( - FontSizeProperty, FontWeightProperty, FontStyleProperty, - FontFamilyProperty, TextTrimmingProperty, TextProperty, - PaddingProperty); - - Observable.Merge( - TextProperty.Changed, - ForegroundProperty.Changed, - TextAlignmentProperty.Changed, - TextWrappingProperty.Changed, - TextTrimmingProperty.Changed, - FontSizeProperty.Changed, - FontStyleProperty.Changed, - FontWeightProperty.Changed, - FontFamilyProperty.Changed, - TextDecorationsProperty.Changed, + AffectsRender(BackgroundProperty, ForegroundProperty, + TextAlignmentProperty, TextDecorationsProperty); + + AffectsMeasure(FontSizeProperty, FontWeightProperty, + FontStyleProperty, TextWrappingProperty, FontFamilyProperty, + TextTrimmingProperty, TextProperty, PaddingProperty); + + Observable.Merge(TextProperty.Changed, ForegroundProperty.Changed, + TextAlignmentProperty.Changed, TextWrappingProperty.Changed, + TextTrimmingProperty.Changed, FontSizeProperty.Changed, + FontStyleProperty.Changed, FontWeightProperty.Changed, + FontFamilyProperty.Changed, TextDecorationsProperty.Changed, PaddingProperty.Changed ).AddClassHandler((x, _) => x.InvalidateTextLayout()); } diff --git a/src/Avalonia.Visuals/Media/GlyphRun.cs b/src/Avalonia.Visuals/Media/GlyphRun.cs index 9b10981fa7..1b54d187d3 100644 --- a/src/Avalonia.Visuals/Media/GlyphRun.cs +++ b/src/Avalonia.Visuals/Media/GlyphRun.cs @@ -274,27 +274,39 @@ namespace Avalonia.Media var currentX = 0.0; var index = 0; - for (; index < GlyphIndices.Length; index++) + if (GlyphTypeface.IsFixedPitch) { - double advance; + var glyph = GlyphIndices[index]; - if (GlyphAdvances.IsEmpty) - { - var glyph = GlyphIndices[index]; + var advance = GlyphTypeface.GetGlyphAdvance(glyph) * Scale; - advance = GlyphTypeface.GetGlyphAdvance(glyph) * Scale; - } - else + index = Math.Min(GlyphIndices.Length - 1, + (int)Math.Round(distance / advance, MidpointRounding.AwayFromZero)); + } + else + { + for (; index < GlyphIndices.Length; index++) { - advance = GlyphAdvances[index]; - } + double advance; - if (currentX + advance >= distance) - { - break; - } + if (GlyphAdvances.IsEmpty) + { + var glyph = GlyphIndices[index]; + + advance = GlyphTypeface.GetGlyphAdvance(glyph) * Scale; + } + else + { + advance = GlyphAdvances[index]; + } - currentX += advance; + if (currentX + advance >= distance) + { + break; + } + + currentX += advance; + } } var characterHit = FindNearestCharacterHit(GlyphClusters[index], out var width); @@ -473,9 +485,7 @@ namespace Avalonia.Media /// private Rect CalculateBounds() { - var scale = FontRenderingEmSize / GlyphTypeface.DesignEmHeight; - - var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * scale; + var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale; var width = 0.0; diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs index 30d513386e..f84e45d4c6 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs @@ -10,15 +10,7 @@ namespace Avalonia.Media.TextFormatting { private static readonly ReadOnlySlice s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); - /// - /// Formats a text line. - /// - /// The text source. - /// The first character index to start the text line from. - /// A value that specifies the width of the paragraph that the line fills. - /// A value that represents paragraph properties, - /// such as TextWrapping, TextAlignment, or TextStyle. - /// The formatted line. + /// public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties) { @@ -61,17 +53,18 @@ namespace Avalonia.Media.TextFormatting /// private List FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer) { - var start = firstTextSourceIndex; + var start = -1; + var length = 0; var textRuns = new List(); while (true) { - var textRun = textSource.GetTextRun(firstTextSourceIndex); + var textRun = textSource.GetTextRun(firstTextSourceIndex + length); - if (textRun.Text.IsEmpty) + if (start == -1) { - break; + start = textRun.Text.Start; } if (textRun is TextEndOfLine) @@ -79,29 +72,33 @@ namespace Avalonia.Media.TextFormatting break; } - if (!(textRun is TextCharacters)) + switch (textRun) { - throw new NotSupportedException("Run type not supported by the formatter."); - } + case TextCharacters textCharacters: - var runText = textRun.Text; + var runText = textCharacters.Text; - while (!runText.IsEmpty) - { - var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style); + while (!runText.IsEmpty) + { + var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style); - var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length), - shapableTextStyleRun.Style); + var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length), + shapableTextStyleRun.Style); - textRuns.Add(shapedRun); + textRuns.Add(shapedRun); - runText = runText.Skip(shapedRun.Text.Length); + runText = runText.Skip(shapedRun.Text.Length); + } + + break; + default: + throw new NotSupportedException("Run type not supported by the formatter."); } - firstTextSourceIndex += textRun.Text.Length; + length += textRun.Text.Length; } - textPointer = new TextPointer(start, firstTextSourceIndex - start); + textPointer = new TextPointer(start, length); return textRuns; } @@ -115,7 +112,7 @@ namespace Avalonia.Media.TextFormatting /// The text runs to perform the trimming on. /// The text that was used to construct the text runs. /// - private TextLine PerformTextTrimming(TextPointer text, IReadOnlyList textRuns, + private static TextLine PerformTextTrimming(TextPointer text, IReadOnlyList textRuns, double paragraphWidth, TextParagraphProperties paragraphProperties) { var textTrimming = paragraphProperties.TextTrimming; @@ -195,7 +192,7 @@ namespace Avalonia.Media.TextFormatting /// The text to analyze for break opportunities. /// /// - private TextLine PerformTextWrapping(TextPointer text, IReadOnlyList textRuns, + private static TextLine PerformTextWrapping(TextPointer text, IReadOnlyList textRuns, double paragraphWidth, TextParagraphProperties paragraphProperties) { var availableWidth = paragraphWidth; @@ -267,41 +264,13 @@ namespace Avalonia.Media.TextFormatting /// The text run. /// The available width. /// - private int MeasureText(ShapedTextRun textRun, double availableWidth) + private static int MeasureText(ShapedTextRun textRun, double availableWidth) { - if (textRun.GlyphRun.Bounds.Width < availableWidth) - { - return textRun.Text.Length; - } - - var measuredWidth = 0.0; - - var index = 0; - - for (; index < textRun.GlyphRun.GlyphAdvances.Length; index++) - { - var advance = textRun.GlyphRun.GlyphAdvances[index]; - - if (measuredWidth + advance > availableWidth) - { - index--; - break; - } - - measuredWidth += advance; - } - - if(index < 0) - { - return 0; - } - - var cluster = textRun.GlyphRun.GlyphClusters[index]; + var glyphRun = textRun.GlyphRun; - var characterHit = textRun.GlyphRun.FindNearestCharacterHit(cluster, out _); + var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _); - return characterHit.FirstCharacterIndex - textRun.GlyphRun.Characters.Start + - (textRun.GlyphRun.IsLeftToRight ? characterHit.TrailingLength : 0); + return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textRun.Text.Start; } /// diff --git a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs b/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs index f0feb7958e..ca6117c5b5 100644 --- a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs +++ b/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs @@ -69,6 +69,11 @@ namespace Avalonia.Utility /// A that contains the specified number of elements from the specified start. public ReadOnlySlice AsSlice(int start, int length) { + if (IsEmpty) + { + return this; + } + if (start < Start || start > End) { throw new ArgumentOutOfRangeException(nameof(start)); @@ -91,6 +96,11 @@ namespace Avalonia.Utility /// A that contains the specified number of elements from the start of this slice. public ReadOnlySlice Take(int length) { + if (IsEmpty) + { + return this; + } + if (length > Length) { throw new ArgumentOutOfRangeException(nameof(length)); @@ -106,6 +116,11 @@ namespace Avalonia.Utility /// A that contains the elements that occur after the specified index in this slice. public ReadOnlySlice Skip(int length) { + if (IsEmpty) + { + return this; + } + if (length > Length) { throw new ArgumentOutOfRangeException(nameof(length)); diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index 63b6cb70da..4adbc2844c 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -173,16 +173,26 @@ namespace Avalonia.Skia using (var textBlobBuilder = new SKTextBlobBuilder()) { + SKTextBlob textBlob; + + width = 0; + var scale = (float)(glyphRun.FontRenderingEmSize / glyphTypeface.DesignEmHeight); if (glyphRun.GlyphOffsets.IsEmpty) { - width = 0; + if (glyphTypeface.IsFixedPitch) + { + textBlobBuilder.AddRun(paint, 0, 0, glyphRun.GlyphIndices.Buffer.Span); - var buffer = textBlobBuilder.AllocateHorizontalRun(paint, count, 0); + textBlob = textBlobBuilder.Build(); - if (!glyphTypeface.IsFixedPitch) + width = glyphTypeface.GetGlyphAdvance(glyphRun.GlyphIndices[0]) * scale * glyphRun.GlyphIndices.Length; + } + else { + var buffer = textBlobBuilder.AllocateHorizontalRun(paint, count, 0); + var positions = buffer.GetPositionSpan(); for (var i = 0; i < count; i++) @@ -198,9 +208,11 @@ namespace Avalonia.Skia width += glyphRun.GlyphAdvances[i]; } } - } - buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); + buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); + + textBlob = textBlobBuilder.Build(); + } } else { @@ -229,9 +241,9 @@ namespace Avalonia.Skia buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); width = currentX; - } - var textBlob = textBlobBuilder.Build(); + textBlob = textBlobBuilder.Build(); + } return new GlyphRunImpl(paint, textBlob); } diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index 32fe48fe49..7a0823a223 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -1,4 +1,5 @@ -using Avalonia.Media; +using System; +using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Platform; @@ -72,36 +73,32 @@ namespace Avalonia.Skia var textScale = textFormat.FontRenderingEmSize / scaleX; - var len = buffer.Length; + var bufferLength = buffer.Length; - var info = buffer.GetGlyphInfoSpan(); + var glyphInfos = buffer.GetGlyphInfoSpan(); - var pos = buffer.GetGlyphPositionSpan(); + var glyphPositions = buffer.GetGlyphPositionSpan(); - var glyphIndices = new ushort[len]; + var glyphIndices = new ushort[bufferLength]; - var clusters = new ushort[len]; + var clusters = new ushort[bufferLength]; - var glyphAdvances = new double[len]; + double[] glyphAdvances = null; - var glyphOffsets = new Vector[len]; + Vector[] glyphOffsets = null; - for (var i = 0; i < len; i++) + for (var i = 0; i < bufferLength; i++) { - glyphIndices[i] = (ushort)info[i].Codepoint; + glyphIndices[i] = (ushort)glyphInfos[i].Codepoint; - clusters[i] = (ushort)(text.Start + info[i].Cluster); + clusters[i] = (ushort)(text.Start + glyphInfos[i].Cluster); - var advanceX = pos[i].XAdvance * textScale; - // Depends on direction of layout - //var advanceY = pos[i].YAdvance * textScale; - - glyphAdvances[i] = advanceX; - - var offsetX = pos[i].XOffset * textScale; - var offsetY = pos[i].YOffset * textScale; + if (!glyphTypeface.IsFixedPitch) + { + SetAdvance(glyphPositions, i, textScale, ref glyphAdvances); + } - glyphOffsets[i] = new Vector(offsetX, offsetY); + SetOffset(glyphPositions, i, textScale, ref glyphOffsets); } return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, @@ -112,5 +109,40 @@ namespace Avalonia.Skia new ReadOnlySlice(clusters)); } } + + private static void SetOffset(ReadOnlySpan glyphPositions, int index, double textScale, + ref Vector[] offsetBuffer) + { + var position = glyphPositions[index]; + + if (position.XOffset == 0 && position.YOffset == 0) + { + return; + } + + if (offsetBuffer == null) + { + offsetBuffer = new Vector[glyphPositions.Length]; + } + + var offsetX = position.XOffset * textScale; + + var offsetY = position.YOffset * textScale; + + offsetBuffer[index] = new Vector(offsetX, offsetY); + } + + private static void SetAdvance(ReadOnlySpan glyphPositions, int index, double textScale, + ref double[] advanceBuffer) + { + if (advanceBuffer == null) + { + advanceBuffer = new double[glyphPositions.Length]; + } + + // Depends on direction of layout + // advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale; + advanceBuffer[index] = glyphPositions[index].XAdvance * textScale; + } } } diff --git a/tests/Avalonia.RenderTests/Assets/NotoSans-Italic.ttf b/tests/Avalonia.RenderTests/Assets/NotoSans-Italic.ttf new file mode 100644 index 0000000000..1639ad7d40 Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/NotoSans-Italic.ttf differ diff --git a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs index a53e2ab188..aef2423f8a 100644 --- a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs +++ b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs @@ -14,12 +14,14 @@ namespace Avalonia.Skia.UnitTests private readonly Typeface _defaultTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); + private readonly Typeface _italicTypeface = + new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans"); private readonly Typeface _emojiTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Twitter Color Emoji"); public CustomFontManagerImpl() { - _customTypefaces = new[] { _emojiTypeface, _defaultTypeface }; + _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; } public string GetDefaultFontFamilyName() @@ -56,6 +58,7 @@ namespace Avalonia.Skia.UnitTests switch (typeface.FontFamily.Name) { case "Twitter Color Emoji": + case "Noto Sans": case "Noto Mono": var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(typeface.FontFamily); var skTypeface = typefaceCollection.Get(typeface); diff --git a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs index f424027910..8e695a11c8 100644 --- a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using Avalonia.Media; using Avalonia.Media.TextFormatting; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.UnitTests; using Avalonia.Utility; using Xunit; @@ -240,7 +242,9 @@ namespace Avalonia.Skia.UnitTests { var cluster = glyphRun.GlyphClusters[i]; - var advance = glyphRun.GlyphAdvances[i]; + var glyph = glyphRun.GlyphIndices[i]; + + var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); @@ -280,7 +284,9 @@ namespace Avalonia.Skia.UnitTests { var cluster = glyphRun.GlyphClusters[i]; - var advance = glyphRun.GlyphAdvances[i]; + var glyph = glyphRun.GlyphIndices[i]; + + var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; characterHit = textLine.GetCharacterHitFromDistance(currentDistance); @@ -296,6 +302,62 @@ namespace Avalonia.Skia.UnitTests } } + [InlineData("Whether to turn off HTTPS. This option only applies if Individual, " + + "IndividualB2C, SingleOrg, or MultiOrg aren't used for ‑‑auth." + , "Noto Sans", 40)] + [InlineData("01234 56789 01234 56789", "Noto Mono", 7)] + [Theory] + public void Should_Wrap_Text(string text, string familyName, int numberOfCharactersPerLine) + { + using (Start()) + { + var lineBreaker = new LineBreakEnumerator(text.AsMemory()); + + var expected = new List(); + + while (lineBreaker.MoveNext()) + { + expected.Add(lineBreaker.Current.PositionWrap - 1); + } + + var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" + + familyName); + + var defaultStyle = new TextStyle(typeface); + + var textSource = new SimpleTextSource(text, defaultStyle); + + var formatter = new SimpleTextFormatter(); + + var glyph = typeface.GlyphTypeface.GetGlyph('a'); + + var advance = typeface.GlyphTypeface.GetGlyphAdvance(glyph) * + (12.0 / typeface.GlyphTypeface.DesignEmHeight); + + var paragraphWidth = advance * numberOfCharactersPerLine; + + var currentPosition = 0; + + while (currentPosition < text.Length) + { + var textLine = + formatter.FormatLine(textSource, currentPosition, paragraphWidth, + new TextParagraphProperties(defaultStyle, textWrapping: TextWrapping.Wrap)); + + Assert.True(expected.Contains(textLine.Text.End)); + + var index = expected.IndexOf(textLine.Text.End); + + for (var i = 0; i <= index; i++) + { + expected.RemoveAt(0); + } + + currentPosition += textLine.Text.Length; + } + } + } + public static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface