// ReSharper disable ForCanBeConvertedToForeach using System; using System.Buffers; using System.Collections.Generic; using System.Runtime.InteropServices; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; using static Avalonia.Media.TextFormatting.FormattingObjectPool; namespace Avalonia.Media.TextFormatting { internal sealed class TextFormatterImpl : TextFormatter { private static readonly char[] s_empty = { ' ' }; private static readonly char[] s_defaultText = new char[TextRun.DefaultTextSourceLength]; [ThreadStatic] private static BidiData? t_bidiData; [ThreadStatic] private static BidiAlgorithm? t_bidiAlgorithm; /// public override TextLine? FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null) { TextLineBreak? nextLineBreak = null; var objectPool = FormattingObjectPool.Instance; var fontManager = FontManager.Current; // we've wrapped the previous line and need to continue wrapping: ignore the textSource and do that instead if (previousLineBreak is WrappingTextLineBreak wrappingTextLineBreak && wrappingTextLineBreak.AcquireRemainingRuns() is { } remainingRuns && paragraphProperties.TextWrapping != TextWrapping.NoWrap) { return PerformTextWrapping(remainingRuns, true, firstTextSourceIndex, paragraphWidth, paragraphProperties, previousLineBreak.FlowDirection, previousLineBreak, objectPool); } RentedList? fetchedRuns = null; RentedList? shapedTextRuns = null; try { fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool, out var textEndOfLine, out var textSourceLength); if (fetchedRuns.Count == 0) { return null; } shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager, out var resolvedFlowDirection); if (nextLineBreak == null && textEndOfLine != null) { nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection); } switch (paragraphProperties.TextWrapping) { case TextWrapping.NoWrap: { var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex, textSourceLength, paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak); textLine.FinalizeLine(); return textLine; } case TextWrapping.WrapWithOverflow: case TextWrapping.Wrap: { return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool); } default: throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping)); } } finally { objectPool.TextRunLists.Return(ref shapedTextRuns); objectPool.TextRunLists.Return(ref fetchedRuns); } } /// /// Split a sequence of runs into two segments at specified length. /// /// The text run's. /// The length to split at. /// A pool used to get reusable formatting objects. /// The split text runs. internal static SplitResult> SplitTextRuns(IReadOnlyList textRuns, int length, FormattingObjectPool objectPool) { var first = objectPool.TextRunLists.Rent(); var currentLength = 0; for (var i = 0; i < textRuns.Count; i++) { var currentRun = textRuns[i]; var currentRunLength = currentRun.Length; if (currentLength + currentRunLength < length) { currentLength += currentRunLength; continue; } var firstCount = currentRunLength >= 1 ? i + 1 : i; if (firstCount > 1) { for (var j = 0; j < i; j++) { first.Add(textRuns[j]); } } var secondCount = textRuns.Count - firstCount; if (currentLength + currentRunLength == length) { var second = secondCount > 0 ? objectPool.TextRunLists.Rent() : null; if (second != null) { var offset = currentRunLength >= 1 ? 1 : 0; for (var j = 0; j < secondCount; j++) { second.Add(textRuns[i + j + offset]); } } first.Add(currentRun); return new SplitResult>(first, second); } else { secondCount++; var second = objectPool.TextRunLists.Rent(); if (currentRun is ShapedTextRun shapedTextCharacters) { var split = shapedTextCharacters.Split(length - currentLength); first.Add(split.First); second.Add(split.Second!); } for (var j = 1; j < secondCount; j++) { second.Add(textRuns[i + j]); } return new SplitResult>(first, second); } } for (var i = 0; i < textRuns.Count; i++) { first.Add(textRuns[i]); } return new SplitResult>(first, null); } /// /// Shape specified text runs with specified paragraph embedding. /// /// The text runs to shape. /// The default paragraph properties. /// The resolved flow direction. /// A pool used to get reusable formatting objects. /// The font manager to use. /// /// A list of shaped text characters. /// private static RentedList ShapeTextRuns(IReadOnlyList textRuns, TextParagraphProperties paragraphProperties, FormattingObjectPool objectPool, FontManager fontManager, out FlowDirection resolvedFlowDirection) { var flowDirection = paragraphProperties.FlowDirection; var shapedRuns = objectPool.TextRunLists.Rent(); if (textRuns.Count == 0) { resolvedFlowDirection = flowDirection; return shapedRuns; } var bidiData = t_bidiData ??= new(); bidiData.Reset(); bidiData.ParagraphEmbeddingLevel = (sbyte)flowDirection; for (var i = 0; i < textRuns.Count; ++i) { var textRun = textRuns[i]; ReadOnlySpan text; if (!textRun.Text.IsEmpty) text = textRun.Text.Span; else if (textRun.Length == TextRun.DefaultTextSourceLength) text = s_defaultText; else text = new char[textRun.Length]; bidiData.Append(text); } var bidiAlgorithm = t_bidiAlgorithm ??= new(); bidiAlgorithm.Process(bidiData); var resolvedEmbeddingLevel = bidiAlgorithm.ResolveEmbeddingLevel(bidiData.Classes); resolvedFlowDirection = (resolvedEmbeddingLevel & 1) == 0 ? FlowDirection.LeftToRight : FlowDirection.RightToLeft; var processedRuns = objectPool.TextRunLists.Rent(); var groupedRuns = objectPool.UnshapedTextRunLists.Rent(); try { CoalesceLevels(textRuns, bidiAlgorithm.ResolvedLevels.Span, fontManager, processedRuns); bidiData.Reset(); bidiAlgorithm.Reset(); var textShaper = TextShaper.Current; for (var index = 0; index < processedRuns.Count; index++) { var currentRun = processedRuns[index]; switch (currentRun) { case UnshapedTextRun shapeableRun: { groupedRuns.Clear(); groupedRuns.Add(shapeableRun); var text = shapeableRun.Text; var properties = shapeableRun.Properties; while (index + 1 < processedRuns.Count) { if (processedRuns[index + 1] is not UnshapedTextRun nextRun) { break; } if (shapeableRun.BidiLevel == nextRun.BidiLevel && TryJoinContiguousMemories(text, nextRun.Text, out var joinedText) && CanShapeTogether(properties, nextRun.Properties)) { groupedRuns.Add(nextRun); index++; shapeableRun = nextRun; text = joinedText; continue; } break; } var shaperOptions = new TextShaperOptions( properties.CachedGlyphTypeface, properties.FontRenderingEmSize, shapeableRun.BidiLevel, properties.CultureInfo, paragraphProperties.DefaultIncrementalTab, paragraphProperties.LetterSpacing); ShapeTogether(groupedRuns, text, shaperOptions, textShaper, shapedRuns); break; } default: { shapedRuns.Add(currentRun); break; } } } } finally { objectPool.TextRunLists.Return(ref processedRuns); objectPool.UnshapedTextRunLists.Return(ref groupedRuns); } return shapedRuns; } /// /// Tries to join two potnetially contiguous memory regions. /// /// The first memory region. /// The second memory region. /// On success, a memory region representing the union of the two regions. /// true if the two regions were contigous; false otherwise. private static bool TryJoinContiguousMemories(ReadOnlyMemory x, ReadOnlyMemory y, out ReadOnlyMemory joinedMemory) { if (MemoryMarshal.TryGetString(x, out var xString, out var xStart, out var xLength)) { if (MemoryMarshal.TryGetString(y, out var yString, out var yStart, out var yLength) && ReferenceEquals(xString, yString) && TryGetContiguousStart(xStart, xLength, yStart, yLength, out var joinedStart)) { joinedMemory = xString.AsMemory(joinedStart, xLength + yLength); return true; } } else if (MemoryMarshal.TryGetArray(x, out var xSegment)) { if (MemoryMarshal.TryGetArray(y, out var ySegment) && ReferenceEquals(xSegment.Array, ySegment.Array) && TryGetContiguousStart(xSegment.Offset, xSegment.Count, ySegment.Offset, ySegment.Count, out var joinedStart)) { joinedMemory = xSegment.Array.AsMemory(joinedStart, xSegment.Count + ySegment.Count); return true; } } else if (MemoryMarshal.TryGetMemoryManager(x, out MemoryManager? xManager, out xStart, out xLength)) { if (MemoryMarshal.TryGetMemoryManager(y, out MemoryManager? yManager, out var yStart, out var yLength) && ReferenceEquals(xManager, yManager) && TryGetContiguousStart(xStart, xLength, yStart, yLength, out var joinedStart)) { joinedMemory = xManager.Memory.Slice(joinedStart, xLength + yLength); return true; } } joinedMemory = default; return false; static bool TryGetContiguousStart(int xStart, int xLength, int yStart, int yLength, out int joinedStart) { var xRange = (Start: xStart, Length: xLength); var yRange = (Start: yStart, Length: yLength); var (firstRange, secondRange) = xStart <= yStart ? (xRange, yRange) : (yRange, xRange); if (firstRange.Start + firstRange.Length == secondRange.Start) { joinedStart = firstRange.Start; return true; } joinedStart = default; return false; } } private static bool CanShapeTogether(TextRunProperties x, TextRunProperties y) => MathUtilities.AreClose(x.FontRenderingEmSize, y.FontRenderingEmSize) && x.Typeface == y.Typeface && x.BaselineAlignment == y.BaselineAlignment; private static void ShapeTogether(IReadOnlyList textRuns, ReadOnlyMemory text, TextShaperOptions options, TextShaper textShaper, RentedList results) { var shapedBuffer = textShaper.ShapeText(text, options); for (var i = 0; i < textRuns.Count; i++) { var currentRun = textRuns[i]; var splitResult = shapedBuffer.Split(currentRun.Length); results.Add(new ShapedTextRun(splitResult.First, currentRun.Properties)); shapedBuffer = splitResult.Second!; } } /// /// Coalesces ranges of the same bidi level to form /// /// The text characters to form from. /// The bidi levels. /// The font manager to use. /// A list that will be filled with the processed runs. /// private static void CoalesceLevels(IReadOnlyList textCharacters, ReadOnlySpan levels, FontManager fontManager, RentedList processedRuns) { if (levels.Length == 0) { return; } var levelIndex = 0; var runLevel = levels[0]; TextRunProperties? previousProperties = null; TextCharacters? currentRun = null; ReadOnlyMemory runText = default; for (var i = 0; i < textCharacters.Count; i++) { var j = 0; currentRun = textCharacters[i] as TextCharacters; if (currentRun == null) { var drawableRun = textCharacters[i]; processedRuns.Add(drawableRun); levelIndex += drawableRun.Length; continue; } runText = currentRun.Text; var runTextSpan = runText.Span; for (; j < runTextSpan.Length;) { Codepoint.ReadAt(runTextSpan, j, out var count); if (levelIndex + 1 == levels.Length) { break; } levelIndex++; j += count; if (j == runTextSpan.Length) { currentRun.GetShapeableCharacters(runText.Slice(0, j), runLevel, fontManager, ref previousProperties, processedRuns); runLevel = levels[levelIndex]; continue; } if (levels[levelIndex] == runLevel) { continue; } // End of this run currentRun.GetShapeableCharacters(runText.Slice(0, j), runLevel, fontManager, ref previousProperties, processedRuns); runText = runText.Slice(j); runTextSpan = runText.Span; j = 0; // Move to next run runLevel = levels[levelIndex]; } } if (currentRun is null || runText.IsEmpty) { return; } currentRun.GetShapeableCharacters(runText, runLevel, fontManager, ref previousProperties, processedRuns); } /// /// Fetches text runs. /// /// The text source. /// The first text source index. /// A pool used to get reusable formatting objects. /// On return, the end of line, if any. /// On return, the processed text source length. /// /// The formatted text runs. /// private static RentedList FetchTextRuns(ITextSource textSource, int firstTextSourceIndex, FormattingObjectPool objectPool, out TextEndOfLine? endOfLine, out int textSourceLength) { textSourceLength = 0; endOfLine = null; var textRuns = objectPool.TextRunLists.Rent(); var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex); while (textRunEnumerator.MoveNext()) { TextRun textRun = textRunEnumerator.Current!; if (textRun is TextEndOfLine textEndOfLine) { endOfLine = textEndOfLine; textSourceLength += textEndOfLine.Length; textRuns.Add(textRun); break; } switch (textRun) { case TextCharacters textCharacters: { if (TryGetLineBreak(textCharacters, out var runLineBreak)) { var splitResult = new TextCharacters(textCharacters.Text.Slice(0, runLineBreak.PositionWrap), textCharacters.Properties); textRuns.Add(splitResult); textSourceLength += runLineBreak.PositionWrap; return textRuns; } textRuns.Add(textCharacters); break; } default: { textRuns.Add(textRun); break; } } textSourceLength += textRun.Length; } return textRuns; } private static bool TryGetLineBreak(TextRun textRun, out LineBreak lineBreak) { lineBreak = default; var text = textRun.Text; if (text.IsEmpty) { return false; } var lineBreakEnumerator = new LineBreakEnumerator(text.Span); while (lineBreakEnumerator.MoveNext(out lineBreak)) { if (!lineBreak.Required) { continue; } return lineBreak.PositionWrap >= textRun.Length || true; } return false; } private static int MeasureLength(IReadOnlyList textRuns, double paragraphWidth) { var measuredLength = 0; var currentWidth = 0.0; for (var i = 0; i < textRuns.Count; ++i) { var currentRun = textRuns[i]; switch (currentRun) { case ShapedTextRun shapedTextCharacters: { if (shapedTextCharacters.ShapedBuffer.Length > 0) { var runLength = 0; for (var j = 0; j < shapedTextCharacters.ShapedBuffer.Length; j++) { var currentInfo = shapedTextCharacters.ShapedBuffer[j]; var clusterWidth = currentInfo.GlyphAdvance; GlyphInfo nextInfo = default; while (j + 1 < shapedTextCharacters.ShapedBuffer.Length) { nextInfo = shapedTextCharacters.ShapedBuffer[j + 1]; if (currentInfo.GlyphCluster == nextInfo.GlyphCluster) { clusterWidth += nextInfo.GlyphAdvance; j++; continue; } break; } var clusterLength = Math.Max(0, nextInfo.GlyphCluster - currentInfo.GlyphCluster); if(clusterLength == 0) { clusterLength = currentRun.Length - runLength; } if(clusterLength == 0) { clusterLength = shapedTextCharacters.GlyphRun.Metrics.FirstCluster + currentRun.Length - currentInfo.GlyphCluster; } if (currentWidth + clusterWidth > paragraphWidth) { if (runLength == 0 && measuredLength == 0) { runLength = clusterLength; } return measuredLength + runLength; } currentWidth += clusterWidth; runLength += clusterLength; } measuredLength += runLength; } break; } case DrawableTextRun drawableTextRun: { if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth) { return measuredLength; } measuredLength += currentRun.Length; currentWidth += drawableTextRun.Size.Width; break; } default: { measuredLength += currentRun.Length; break; } } } return measuredLength; } /// /// Creates an empty text line. /// /// The empty text line. public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties) { var flowDirection = paragraphProperties.FlowDirection; var properties = paragraphProperties.DefaultTextRunProperties; var glyphTypeface = properties.CachedGlyphTypeface; var glyph = glyphTypeface.GetGlyph(s_empty[0]); var glyphInfos = new[] { new GlyphInfo(glyph, firstTextSourceIndex, 0.0) }; var shapedBuffer = new ShapedBuffer(s_empty.AsMemory(), glyphInfos, glyphTypeface, properties.FontRenderingEmSize, (sbyte)flowDirection); var textRuns = new TextRun[] { new ShapedTextRun(shapedBuffer, properties) }; var line = new TextLineImpl(textRuns, firstTextSourceIndex, 0, paragraphWidth, paragraphProperties, flowDirection); line.FinalizeLine(); return line; } /// /// Performs text wrapping returns a list of text lines. /// /// /// Whether can be reused to store the split runs. /// The first text source index. /// The paragraph width. /// The text paragraph properties. /// /// The current line break if the line was explicitly broken. /// A pool used to get reusable formatting objects. /// The wrapped text line. private static TextLineImpl PerformTextWrapping(List textRuns, bool canReuseTextRunList, int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection, TextLineBreak? currentLineBreak, FormattingObjectPool objectPool) { if (textRuns.Count == 0) { return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties); } var measuredLength = MeasureLength(textRuns, paragraphWidth); if(measuredLength == 0) { } var currentLength = 0; var lastWrapPosition = 0; var currentPosition = 0; for (var index = 0; index < textRuns.Count; index++) { var breakFound = false; var currentRun = textRuns[index]; switch (currentRun) { case ShapedTextRun: { var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); while (lineBreaker.MoveNext(out var lineBreak)) { if (lineBreak.Required && currentLength + lineBreak.PositionMeasure <= measuredLength) { //Explicit break found breakFound = true; currentPosition = currentLength + lineBreak.PositionWrap; break; } if (currentLength + lineBreak.PositionMeasure > measuredLength) { if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) { if (lastWrapPosition > 0) { currentPosition = lastWrapPosition; breakFound = true; break; } //Find next possible wrap position (overflow) if (index < textRuns.Count - 1) { if (lineBreak.PositionWrap != currentRun.Length) { //We already found the next possible wrap position. breakFound = true; currentPosition = currentLength + lineBreak.PositionWrap; break; } while (lineBreaker.MoveNext(out lineBreak)) { currentPosition += lineBreak.PositionWrap; if (lineBreak.PositionWrap != currentRun.Length) { break; } index++; if (index >= textRuns.Count) { break; } currentRun = textRuns[index]; lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); } } else { currentPosition = currentLength + lineBreak.PositionWrap; } if (currentPosition == 0 && measuredLength > 0) { currentPosition = measuredLength; } breakFound = true; break; } //We overflowed so we use the last available wrap position. currentPosition = lastWrapPosition == 0 ? measuredLength : lastWrapPosition; breakFound = true; break; } if (lineBreak.PositionMeasure != lineBreak.PositionWrap) { lastWrapPosition = currentLength + lineBreak.PositionWrap; } } break; } } if (!breakFound) { currentLength += currentRun.Length; continue; } //We don't want to surpass the measuredLength with trailing whitespace when we are in a right to left setting. if(currentPosition > measuredLength && resolvedFlowDirection == FlowDirection.RightToLeft) { break; } measuredLength = currentPosition; break; } var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool); try { TextLineBreak? textLineBreak; if (postSplitRuns?.Count > 0) { List remainingRuns; // reuse the list as much as possible: // if canReuseTextRunList == true it's coming from previous remaining runs if (canReuseTextRunList) { remainingRuns = textRuns; remainingRuns.Clear(); } else { remainingRuns = new List(); } for (var i = 0; i < postSplitRuns.Count; ++i) { remainingRuns.Add(postSplitRuns[i]); } textLineBreak = new WrappingTextLineBreak(null, resolvedFlowDirection, remainingRuns); } else if (currentLineBreak?.TextEndOfLine is { } textEndOfLine) { textLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection); } else { textLineBreak = null; } var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength, paragraphWidth, paragraphProperties, resolvedFlowDirection, textLineBreak); textLine.FinalizeLine(); return textLine; } finally { objectPool.TextRunLists.Return(ref preSplitRuns); objectPool.TextRunLists.Return(ref postSplitRuns); } } private struct TextRunEnumerator { private readonly ITextSource _textSource; private int _pos; public TextRunEnumerator(ITextSource textSource, int firstTextSourceIndex) { _textSource = textSource; _pos = firstTextSourceIndex; Current = null; } // ReSharper disable once MemberHidesStaticFromOuterClass public TextRun? Current { get; private set; } public bool MoveNext() { Current = _textSource.GetTextRun(_pos); if (Current is null) { return false; } if (Current.Length == 0) { return false; } _pos += Current.Length; return true; } } /// /// Creates a shaped symbol. /// /// The symbol run to shape. /// The flow direction. /// /// The shaped symbol. /// internal static ShapedTextRun CreateSymbol(TextRun textRun, FlowDirection flowDirection) { var textShaper = TextShaper.Current; var glyphTypeface = textRun.Properties!.CachedGlyphTypeface; var fontRenderingEmSize = textRun.Properties.FontRenderingEmSize; var cultureInfo = textRun.Properties.CultureInfo; var shaperOptions = new TextShaperOptions(glyphTypeface, fontRenderingEmSize, (sbyte)flowDirection, cultureInfo); var shapedBuffer = textShaper.ShapeText(textRun.Text, shaperOptions); return new ShapedTextRun(shapedBuffer, textRun.Properties); } } }