diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs index 3f26d081b00..65b89c1ed77 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs @@ -49,7 +49,7 @@ internal ShapedBuffer(ReadOnlyMemory text, ArraySlice glyphInfo /// /// The buffer's bidi level. /// - public sbyte BidiLevel { get; } + public sbyte BidiLevel { get; private set; } /// /// The buffer's reading direction. @@ -169,6 +169,8 @@ internal SplitResult Split(int length) return new SplitResult(first, second); } + internal void ResetBidiLevel(sbyte paragraphEmbeddingLevel) => BidiLevel = paragraphEmbeddingLevel; + int IReadOnlyCollection.Count => _glyphInfos.Length; IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 8e2325fb145..784ee835b40 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -908,6 +908,11 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can textLineBreak = null; } + if (postSplitRuns?.Count > 0) + { + ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool); + } + var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength, paragraphWidth, paragraphProperties, resolvedFlowDirection, textLineBreak); @@ -923,6 +928,86 @@ private static TextLineImpl PerformTextWrapping(List textRuns, bool can } } + private static void ResetTrailingWhitespaceBidiLevels(RentedList lineTextRuns, FlowDirection paragraphFlowDirection, FormattingObjectPool objectPool) + { + if (lineTextRuns.Count == 0) + { + return; + } + + var lastTextRunIndex = lineTextRuns.Count - 1; + + var lastTextRun = lineTextRuns[lastTextRunIndex]; + + if (lastTextRun is not ShapedTextRun shapedText) + { + return; + } + + var paragraphEmbeddingLevel = (sbyte)paragraphFlowDirection; + + if (shapedText.BidiLevel == paragraphEmbeddingLevel) + { + return; + } + + var textSpan = shapedText.Text.Span; + + if (textSpan.IsEmpty) + { + return; + } + + var whitespaceCharactersCount = 0; + + for (var i = textSpan.Length - 1; i >= 0; i--) + { + var isWhitespace = Codepoint.ReadAt(textSpan, i, out _).IsWhiteSpace; + + if (isWhitespace) + { + whitespaceCharactersCount++; + } + else + { + break; + } + } + + if (whitespaceCharactersCount == 0) + { + return; + } + + var splitIndex = shapedText.Length - whitespaceCharactersCount; + + var (textRuns, trailingWhitespaceRuns) = SplitTextRuns([shapedText], splitIndex, objectPool); + + try + { + if (trailingWhitespaceRuns != null) + { + for (var i = 0; i < trailingWhitespaceRuns.Count; i++) + { + if (trailingWhitespaceRuns[i] is ShapedTextRun shapedTextRun) + { + shapedTextRun.ShapedBuffer.ResetBidiLevel(paragraphEmbeddingLevel); + } + } + + lineTextRuns.RemoveAt(lastTextRunIndex); + + lineTextRuns.AddRange(textRuns); + lineTextRuns.AddRange(trailingWhitespaceRuns); + } + } + finally + { + objectPool.TextRunLists.Return(ref textRuns); + objectPool.TextRunLists.Return(ref trailingWhitespaceRuns); + } + } + private struct TextRunEnumerator { private readonly ITextSource _textSource; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 5ff0a45e390..59c9216aec8 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -168,6 +168,87 @@ public void Should_Format_TextLine_With_Non_Text_TextRuns_RightToLeft() } } + [Fact] + public void Should_Reset_Bidi_Levels_Of_Trailing_Whitespaces_After_TextWrapping() + { + using (Start()) + { + const string text = "aaa bbb"; + + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + + var paragraphProperties = new GenericTextParagraphProperties(FlowDirection.RightToLeft, TextAlignment.Right, true, + true, defaultProperties, TextWrapping.Wrap, 0, 0, 0); + + var textSource = new SimpleTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var firstLine = formatter.FormatLine(textSource, 0, 50, paragraphProperties); + + Assert.NotNull(firstLine); + + Assert.Equal(2, firstLine.TextRuns.Count); + + var first = firstLine.TextRuns[0] as ShapedTextRun; + + var second = firstLine.TextRuns[1] as ShapedTextRun; + + Assert.NotNull(first); + + Assert.NotNull(second); + + Assert.Equal(" ", first.Text.ToString()); + + Assert.Equal("aaa", second.Text.ToString()); + + Assert.Equal(1, first.BidiLevel); + + Assert.Equal(2, second.BidiLevel); + } + } + + + [Fact] + public void Should_Reset_Bidi_Levels_Of_Trailing_Whitespaces_After_TextWrapping_2() + { + using (Start()) + { + const string text = "אאא בבב"; + + var defaultProperties = new GenericTextRunProperties(Typeface.Default); + + var paragraphProperties = new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left, true, + true, defaultProperties, TextWrapping.Wrap, 0, 0, 0); + + var textSource = new SimpleTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var firstLine = formatter.FormatLine(textSource, 0, 40, paragraphProperties); + + Assert.NotNull(firstLine); + + Assert.Equal(2, firstLine.TextRuns.Count); + + var first = firstLine.TextRuns[0] as ShapedTextRun; + + var second = firstLine.TextRuns[1] as ShapedTextRun; + + Assert.NotNull(first); + + Assert.NotNull(second); + + Assert.Equal("אאא", first.Text.ToString()); + + Assert.Equal(" ", second.Text.ToString()); + + Assert.Equal(1, first.BidiLevel); + + Assert.Equal(0, second.BidiLevel); + } + } + [Fact] public void Should_Format_TextRuns_With_TextRunStyles() {