Browse Source

Rework TextLineImpl.GetTextBounds (#19576)

* Rework TextLineImpl.GetTextBounds

* Minor adjustments

* Fix GlyphRun.GetDistanceFromCharacterHit in cluster hit
pull/19583/head
Benedikt Stebner 5 months ago
committed by GitHub
parent
commit
27859b9e54
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 27
      src/Avalonia.Base/Media/GlyphRun.cs
  2. 406
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  3. 2
      tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs
  4. BIN
      tests/Avalonia.RenderTests/Assets/Manrope-Light.ttf
  5. 99
      tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs
  6. 233
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

27
src/Avalonia.Base/Media/GlyphRun.cs

@ -243,6 +243,7 @@ namespace Avalonia.Media
public double GetDistanceFromCharacterHit(CharacterHit characterHit) public double GetDistanceFromCharacterHit(CharacterHit characterHit)
{ {
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var isTrailingHit = characterHit.TrailingLength > 0;
var distance = 0.0; var distance = 0.0;
@ -260,10 +261,28 @@ namespace Avalonia.Media
var glyphIndex = FindGlyphIndex(characterIndex); var glyphIndex = FindGlyphIndex(characterIndex);
var currentCluster = _glyphInfos[glyphIndex].GlyphCluster; var glyphInfo = _glyphInfos[glyphIndex];
var currentCluster = glyphInfo.GlyphCluster;
var inClusterHit = currentCluster < characterIndex;
//For in cluster hits we need to move to the start of the next cluster.
if (inClusterHit)
{
for(; glyphIndex < _glyphInfos.Count; glyphIndex++)
{
if (_glyphInfos[glyphIndex].GlyphCluster > characterIndex)
{
break;
}
}
isTrailingHit = false;
}
//Move to the end of the glyph cluster //Move to the end of the glyph cluster
if (characterHit.TrailingLength > 0) if (isTrailingHit)
{ {
while (glyphIndex + 1 < _glyphInfos.Count && _glyphInfos[glyphIndex + 1].GlyphCluster == currentCluster) while (glyphIndex + 1 < _glyphInfos.Count && _glyphInfos[glyphIndex + 1].GlyphCluster == currentCluster)
{ {
@ -347,8 +366,8 @@ namespace Avalonia.Media
characterIndex = glyphInfo.GlyphCluster; characterIndex = glyphInfo.GlyphCluster;
if (distance > currentX && distance <= currentX + advance) if (currentX + advance > distance)
{ {
break; break;
} }

406
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities; using Avalonia.Utilities;
@ -395,22 +396,22 @@ namespace Avalonia.Media.TextFormatting
return currentDirection; return currentDirection;
} }
IndexedTextRun FindIndexedRun() IndexedTextRun FindIndexedRun(out int index)
{ {
var i = 0; index = 0;
IndexedTextRun currentIndexedRun = _indexedTextRuns[i]; IndexedTextRun currentIndexedRun = _indexedTextRuns[index];
while (currentIndexedRun.TextSourceCharacterIndex != currentPosition) while (currentIndexedRun.TextSourceCharacterIndex != currentPosition)
{ {
if (i + 1 == _indexedTextRuns.Count) if (index + 1 == _indexedTextRuns.Count)
{ {
break; break;
} }
i++; index++;
currentIndexedRun = _indexedTextRuns[i]; currentIndexedRun = _indexedTextRuns[index];
} }
return currentIndexedRun; return currentIndexedRun;
@ -434,7 +435,8 @@ namespace Avalonia.Media.TextFormatting
} }
TextRun? currentTextRun = null; TextRun? currentTextRun = null;
var currentIndexedRun = FindIndexedRun();
var currentIndexedRun = FindIndexedRun(out var indexedRunIndex);
while (currentPosition < FirstTextSourceIndex + Length) while (currentPosition < FirstTextSourceIndex + Length)
{ {
@ -451,7 +453,7 @@ namespace Avalonia.Media.TextFormatting
{ {
currentPosition += currentTextRun.Length; currentPosition += currentTextRun.Length;
currentIndexedRun = FindIndexedRun(); currentIndexedRun = FindIndexedRun(out indexedRunIndex);
continue; continue;
} }
@ -467,7 +469,6 @@ namespace Avalonia.Media.TextFormatting
var directionalWidth = 0.0; var directionalWidth = 0.0;
var firstRunIndex = currentIndexedRun.RunIndex; var firstRunIndex = currentIndexedRun.RunIndex;
var lastRunIndex = firstRunIndex;
var currentDirection = GetDirection(currentTextRun, _resolvedFlowDirection); var currentDirection = GetDirection(currentTextRun, _resolvedFlowDirection);
@ -478,51 +479,7 @@ namespace Avalonia.Media.TextFormatting
directionalWidth = currentDrawable.Size.Width; directionalWidth = currentDrawable.Size.Width;
} }
if (currentTextRun is not TextEndOfLine) var lastRunIndex = GetLastDirectionalRunIndex(indexedRunIndex, currentDirection, ref directionalWidth);
{
if (currentDirection == FlowDirection.LeftToRight)
{
// Find consecutive runs of same direction
for (; lastRunIndex + 1 < _textRuns.Length; lastRunIndex++)
{
var nextRun = _textRuns[lastRunIndex + 1];
var nextDirection = GetDirection(nextRun, currentDirection);
if (currentDirection != nextDirection)
{
break;
}
if (nextRun is DrawableTextRun nextDrawable)
{
directionalWidth += nextDrawable.Size.Width;
}
}
}
else
{
// Find consecutive runs of same direction
for (; firstRunIndex - 1 > 0; firstRunIndex--)
{
var previousRun = _textRuns[firstRunIndex - 1];
var previousDirection = GetDirection(previousRun, currentDirection);
if (currentDirection != previousDirection)
{
break;
}
if (previousRun is DrawableTextRun previousDrawable)
{
directionalWidth += previousDrawable.Size.Width;
currentX -= previousDrawable.Size.Width;
}
}
}
}
switch (currentDirection) switch (currentDirection)
{ {
@ -600,6 +557,71 @@ namespace Avalonia.Media.TextFormatting
return GetPreviousCharacterHit(characterHit, true); return GetPreviousCharacterHit(characterHit, true);
} }
private static FlowDirection GetRunDirection(TextRun? textRun, FlowDirection currentDirection)
{
if (textRun is ShapedTextRun shapedTextRun)
{
return shapedTextRun.ShapedBuffer.IsLeftToRight ?
FlowDirection.LeftToRight :
FlowDirection.RightToLeft;
}
return currentDirection;
}
/// <summary>
/// Get the last consecutive visual run index that shares the same direction as the current direction.
/// </summary>
/// <param name="indexedRunIndex">The current logical run's index.</param>
/// <param name="flowDirection">The current flow direction.</param>
/// <param name="directionalWidth">The current directional width.</param>
/// <returns>
/// The last consecutive visual run index that shares the same direction as the current direction.
/// </returns>
private int GetLastDirectionalRunIndex(int indexedRunIndex, FlowDirection flowDirection, ref double directionalWidth)
{
if(_indexedTextRuns is null)
{
return -1;
}
var lastRunIndex = _indexedTextRuns[indexedRunIndex].RunIndex;
// Find consecutive runs of same direction
while (indexedRunIndex + 1 < _indexedTextRuns.Count)
{
var nextIndexedRun = _indexedTextRuns[++indexedRunIndex];
if (nextIndexedRun.RunIndex != lastRunIndex + 1)
{
break;
}
var nextRun = nextIndexedRun.TextRun;
if (nextRun is null)
{
break;
}
var nextDirection = GetRunDirection(nextRun, flowDirection);
if (nextDirection != flowDirection)
{
break;
}
if (nextRun is DrawableTextRun nextDrawable)
{
directionalWidth += nextDrawable.Size.Width;
}
lastRunIndex = nextIndexedRun.RunIndex;
}
return lastRunIndex;
}
public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength) public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
{ {
if(textLength == 0) if(textLength == 0)
@ -619,7 +641,7 @@ namespace Avalonia.Media.TextFormatting
if (firstTextSourceIndex + textLength < FirstTextSourceIndex) if (firstTextSourceIndex + textLength < FirstTextSourceIndex)
{ {
var indexedTextRun = _indexedTextRuns[0]; var indexedTextRun = _indexedTextRuns[0];
var currentDirection = GetDirection(indexedTextRun.TextRun, _resolvedFlowDirection); var currentDirection = GetRunDirection(indexedTextRun.TextRun, _resolvedFlowDirection);
return [new TextBounds(new Rect(0,0,0, Height), currentDirection, [])]; return [new TextBounds(new Rect(0,0,0, Height), currentDirection, [])];
} }
@ -628,7 +650,7 @@ namespace Avalonia.Media.TextFormatting
if (firstTextSourceIndex >= FirstTextSourceIndex + Length) if (firstTextSourceIndex >= FirstTextSourceIndex + Length)
{ {
var indexedTextRun = _indexedTextRuns[_indexedTextRuns.Count - 1]; var indexedTextRun = _indexedTextRuns[_indexedTextRuns.Count - 1];
var currentDirection = GetDirection(indexedTextRun.TextRun, _resolvedFlowDirection); var currentDirection = GetRunDirection(indexedTextRun.TextRun, _resolvedFlowDirection);
return [new TextBounds(new Rect(WidthIncludingTrailingWhitespace, 0, 0, Height), currentDirection, [])]; return [new TextBounds(new Rect(WidthIncludingTrailingWhitespace, 0, 0, Height), currentDirection, [])];
} }
@ -639,16 +661,13 @@ namespace Avalonia.Media.TextFormatting
while (remainingLength > 0 && currentPosition < FirstTextSourceIndex + Length) while (remainingLength > 0 && currentPosition < FirstTextSourceIndex + Length)
{ {
var currentIndexedRun = FindIndexedRun(); var currentIndexedRun = FindIndexedRun(out var indexedRunIndex);
if (currentIndexedRun == null) if (currentIndexedRun == null)
{ {
break; break;
} }
var directionalWidth = 0.0;
var firstRunIndex = currentIndexedRun.RunIndex;
var lastRunIndex = firstRunIndex;
var currentTextRun = currentIndexedRun.TextRun; var currentTextRun = currentIndexedRun.TextRun;
if (currentTextRun == null) if (currentTextRun == null)
@ -656,7 +675,7 @@ namespace Avalonia.Media.TextFormatting
break; break;
} }
var currentDirection = GetDirection(currentTextRun, _resolvedFlowDirection); var currentDirection = GetRunDirection(currentTextRun, _resolvedFlowDirection);
if (currentIndexedRun.TextSourceCharacterIndex + currentTextRun.Length <= firstTextSourceIndex) if (currentIndexedRun.TextSourceCharacterIndex + currentTextRun.Length <= firstTextSourceIndex)
{ {
@ -666,11 +685,15 @@ namespace Avalonia.Media.TextFormatting
} }
var currentX = Start + GetPreceedingDistance(currentIndexedRun.RunIndex); var currentX = Start + GetPreceedingDistance(currentIndexedRun.RunIndex);
var directionalWidth = 0.0;
if (currentTextRun is DrawableTextRun currentDrawable) if (currentTextRun is DrawableTextRun currentDrawable)
{ {
directionalWidth = currentDrawable.Size.Width; directionalWidth = currentDrawable.Size.Width;
} }
var firstRunIndex = currentIndexedRun.RunIndex;
var lastRunIndex = GetLastDirectionalRunIndex(indexedRunIndex, currentDirection, ref directionalWidth);
TextBounds currentBounds; TextBounds currentBounds;
int coveredLength; int coveredLength;
@ -686,7 +709,7 @@ namespace Avalonia.Media.TextFormatting
} }
default: default:
{ {
currentBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex, currentBounds = GetTextBoundsLeftToRight(firstRunIndex, lastRunIndex, currentX, firstTextSourceIndex,
currentPosition, remainingLength, out coveredLength, out currentPosition); currentPosition, remainingLength, out coveredLength, out currentPosition);
break; break;
@ -718,34 +741,22 @@ namespace Avalonia.Media.TextFormatting
return result; return result;
static FlowDirection GetDirection(TextRun? textRun, FlowDirection currentDirection) IndexedTextRun FindIndexedRun(out int index)
{ {
if (textRun is ShapedTextRun shapedTextRun) index = 0;
{
return shapedTextRun.ShapedBuffer.IsLeftToRight ?
FlowDirection.LeftToRight :
FlowDirection.RightToLeft;
}
return currentDirection; var currentIndexedRun = _indexedTextRuns[index];
}
IndexedTextRun FindIndexedRun()
{
var i = 0;
var currentIndexedRun = _indexedTextRuns[i];
while (currentIndexedRun.TextSourceCharacterIndex != currentPosition) while (currentIndexedRun.TextSourceCharacterIndex != currentPosition)
{ {
if (i + 1 == _indexedTextRuns.Count) if (index + 1 == _indexedTextRuns.Count)
{ {
break; break;
} }
i++; index++;
currentIndexedRun = _indexedTextRuns[i]; currentIndexedRun = _indexedTextRuns[index];
} }
return currentIndexedRun; return currentIndexedRun;
@ -905,14 +916,14 @@ namespace Avalonia.Media.TextFormatting
if (currentRun is ShapedTextRun shapedTextRun) if (currentRun is ShapedTextRun shapedTextRun)
{ {
var runBounds = GetRunBoundsRightToLeft(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition, out var offset); var runBounds = GetRunBoundsRightToLeft(shapedTextRun, startX, firstTextSourceIndex, remainingLength, currentPosition);
if (runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length) if (runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
{ {
textRunBounds.Insert(0, runBounds); textRunBounds.Insert(0, runBounds);
} }
if (offset > 0) if (i == lastRunIndex)
{ {
endX = runBounds.Rectangle.Right; endX = runBounds.Rectangle.Right;
@ -921,7 +932,7 @@ namespace Avalonia.Media.TextFormatting
startX -= runBounds.Rectangle.Width; startX -= runBounds.Rectangle.Width;
currentPosition += runBounds.Length + offset; currentPosition = runBounds.TextSourceCharacterIndex + runBounds.Length;
coveredLength += runBounds.Length; coveredLength += runBounds.Length;
@ -985,23 +996,21 @@ namespace Avalonia.Media.TextFormatting
if (currentRun is ShapedTextRun shapedTextRun) if (currentRun is ShapedTextRun shapedTextRun)
{ {
var runBounds = GetRunBoundsLeftToRight(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition, out var offset); var runBounds = GetRunBoundsLeftToRight(shapedTextRun, endX, firstTextSourceIndex, remainingLength, currentPosition);
if(runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length) if(runBounds.TextSourceCharacterIndex < FirstTextSourceIndex + Length)
{ {
textRunBounds.Add(runBounds); textRunBounds.Add(runBounds);
} }
if (offset > 0) currentPosition = runBounds.TextSourceCharacterIndex + runBounds.Length;
if(i == firstRunIndex)
{ {
startX = runBounds.Rectangle.Left; startX = runBounds.Rectangle.Left;
endX = startX;
} }
currentPosition += runBounds.Length + offset; endX = runBounds.Rectangle.Right;
endX += runBounds.Rectangle.Width;
coveredLength += runBounds.Length; coveredLength += runBounds.Length;
@ -1054,189 +1063,154 @@ namespace Avalonia.Media.TextFormatting
} }
private TextRunBounds GetRunBoundsLeftToRight(ShapedTextRun currentRun, double startX, private TextRunBounds GetRunBoundsLeftToRight(ShapedTextRun currentRun, double startX,
int firstTextSourceIndex, int remainingLength, int currentPosition, out int offset) int firstTextSourceIndex, int remainingLength, int currentPosition)
{ {
var startIndex = currentPosition; //Determine the start of the first hit in local positions.
var runOffset = Math.Max(0, firstTextSourceIndex - currentPosition);
offset = Math.Max(0, firstTextSourceIndex - currentPosition);
var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster; var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster;
if (currentPosition != firstCluster) //The start index needs to be relative to the first cluster
{ var startIndex = firstCluster + runOffset;
startIndex = firstCluster + offset; var endIndex = startIndex + remainingLength;
}
else
{
startIndex += offset;
}
//Make sure we start the hit test at the start of the possible cluster. //Current position is a text source index and first cluster is relative to the GlyphRun's buffer.
var clusterStartHit = currentRun.GlyphRun.GetPreviousCaretCharacterHit(new CharacterHit(startIndex)); var textSourceOffset = currentPosition - firstCluster;
var clusterEndHit = currentRun.GlyphRun.GetNextCaretCharacterHit(clusterStartHit);
Debug.Assert(textSourceOffset >= 0);
var clusterOffset = 0; var clusterOffset = 0;
if (startIndex > clusterStartHit.FirstCharacterIndex && startIndex < clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength) //Cluster boundary correction
{ if (runOffset > 0)
clusterOffset = clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength - startIndex; {
var characterHit = currentRun.GlyphRun.FindNearestCharacterHit(startIndex, out _);
var clusterStart = characterHit.FirstCharacterIndex;
var clusterEnd = clusterStart + characterHit.TrailingLength;
//Test against left and right edge
if (clusterStart < startIndex && clusterEnd > startIndex)
{
//Remember the cluster correction offset
clusterOffset = startIndex - clusterStart;
//We need to move the startIndex to the start of the cluster. //Move to the start of the cluster
startIndex -= clusterOffset; startIndex -= clusterOffset;
}
} }
//Find the visual start and end position we want to hit test against
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(endIndex));
// Preserve non-zero width for zero-advance ranges
if (startOffset == endOffset && startIndex != endIndex)
{
//We need to make sure a zero width text line is hit test at the end so we add some delta
endOffset += MathUtilities.DoubleEpsilon;
}
var endX = startX + endOffset; var endX = startX + endOffset;
startX += startOffset; startX += startOffset;
//Hit test against visual positions
var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHitIndex = startHit.FirstCharacterIndex + startHit.TrailingLength;
var endHitIndex = endHit.FirstCharacterIndex + endHit.TrailingLength;
//Adjust characterLength by the cluster offset to only cover the remaining length of the cluster. //Adjust characterLength by the cluster offset to only cover the remaining length of the cluster.
var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - var characterLength = Math.Max(0, Math.Abs(startHitIndex - endHitIndex) - clusterOffset);
endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset);
remainingLength -= characterLength;
var runOffset = startIndex - firstCluster;
//Make sure we are properly dealing with zero width space runs
if (remainingLength > 0 && currentRun.Text.Length > 0 && runOffset + characterLength < currentRun.Text.Length)
{
var glyphInfos = currentRun.GlyphRun.GlyphInfos;
for (int i = runOffset + characterLength; i < glyphInfos.Count; i++)
{
var glyphInfo = glyphInfos[i];
if(glyphInfo.GlyphAdvance > 0)
{
break;
}
var graphemeEnumerator = new GraphemeEnumerator(currentRun.Text.Span.Slice(runOffset + characterLength));
if(!graphemeEnumerator.MoveNext(out var grapheme))
{
break;
}
characterLength += grapheme.Length - clusterOffset;
remainingLength -= grapheme.Length;
if(remainingLength <= 0)
{
break;
}
}
}
// Normalize bounds
if (endX < startX) if (endX < startX)
{ {
(endX, startX) = (startX, endX); (endX, startX) = (startX, endX);
} }
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
var runWidth = endX - startX; var runWidth = endX - startX;
var textSourceIndex = startIndex + Math.Max(0, currentPosition - firstCluster) + clusterOffset; //We need to adjust the local position to the text source
var textSourceIndex = textSourceOffset + startHitIndex + clusterOffset;
return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun);
} }
private TextRunBounds GetRunBoundsRightToLeft(ShapedTextRun currentRun, double endX, private TextRunBounds GetRunBoundsRightToLeft(ShapedTextRun currentRun, double endX, int firstTextSourceIndex, int remainingLength, int currentPosition)
int firstTextSourceIndex, int remainingLength, int currentPosition, out int offset)
{ {
// We start from the right edge of the run
var startX = endX; var startX = endX;
var startIndex = currentPosition; //Determine the start of the first hit in local positions.
var runOffset = Math.Max(0, firstTextSourceIndex - currentPosition);
offset = Math.Max(0, firstTextSourceIndex - currentPosition);
var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster; var firstCluster = currentRun.GlyphRun.Metrics.FirstCluster;
if (currentPosition != firstCluster) //The start index needs to be relative to the first cluster
{ var startIndex = firstCluster + runOffset;
startIndex = firstCluster + offset; var endIndex = startIndex + remainingLength;
}
else
{
startIndex += offset;
}
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
//Make sure we start the hit test at the start of the possible cluster. //Current position is a text source index and first cluster is relative to the GlyphRun's buffer.
var clusterStartHit = currentRun.GlyphRun.GetNextCaretCharacterHit(new CharacterHit(startIndex)); var textSourceOffset = currentPosition - firstCluster;
var clusterEndHit = currentRun.GlyphRun.GetPreviousCaretCharacterHit(startHit); Debug.Assert(textSourceOffset >= 0);
var clusterOffset = 0; var clusterOffset = 0;
if (startIndex > clusterStartHit.FirstCharacterIndex && startIndex < clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength) //Cluster boundary correction
if (runOffset > 0)
{ {
clusterOffset = clusterEndHit.FirstCharacterIndex + clusterEndHit.TrailingLength - startIndex; var characterHit = currentRun.GlyphRun.FindNearestCharacterHit(startIndex, out _);
//We need to move the startIndex to the start of the cluster. var clusterStart = characterHit.FirstCharacterIndex;
startIndex -= clusterOffset; var clusterEnd = clusterStart + characterHit.TrailingLength;
}
var characterLength = Math.Max(0, Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength -
endHit.FirstCharacterIndex - endHit.TrailingLength) - clusterOffset);
var runOffset = startIndex - offset;
if (characterLength == 0 && currentRun.Text.Length > 0 && runOffset < currentRun.Text.Length)
{
//Make sure we are properly dealing with zero width space runs
var codepointEnumerator = new CodepointEnumerator(currentRun.Text.Span.Slice(runOffset));
while (remainingLength > 0 && codepointEnumerator.MoveNext(out var codepoint)) //Test against left and right edge
if (clusterStart < startIndex && clusterEnd > startIndex)
{ {
if (codepoint.IsWhiteSpace) //Remember the cluster correction offset
{ clusterOffset = startIndex - clusterStart;
characterLength++;
remainingLength--; //Move to the start of the cluster
} startIndex -= clusterOffset;
else
{
break;
}
} }
} }
if (startHit.FirstCharacterIndex > endHit.FirstCharacterIndex) //Find the visual start and end position we want to hit test against
var startOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var endOffset = currentRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(endIndex));
//We need the distance from right to left and GetDistanceFromCharacterHit returs a distance from left to right so we need to adjust the offsets
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
// Preserve non-zero width for zero-advance ranges
if (startOffset == endOffset && startIndex != endIndex)
{ {
startHit = endHit; //We need to make sure a zero width text line is hit test at the end so we add some delta
endOffset += MathUtilities.DoubleEpsilon;
} }
//Hit test against visual positions
var startHit = currentRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
var endHit = currentRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHitIndex = startHit.FirstCharacterIndex + startHit.TrailingLength;
var endHitIndex = endHit.FirstCharacterIndex + endHit.TrailingLength;
var characterLength = Math.Max(0, Math.Abs(startHitIndex - endHitIndex) - clusterOffset);
// Normalize bounds
if (endX < startX) if (endX < startX)
{ {
(endX, startX) = (startX, endX); (endX, startX) = (startX, endX);
} }
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
var runWidth = endX - startX; var runWidth = endX - startX;
var textSourceIndex = startIndex + Math.Max(0, currentPosition - firstCluster) + clusterOffset; //We need to adjust the local position to the text source
var textSourceIndex = textSourceOffset + startHitIndex + clusterOffset;
return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun);
} }

2
tests/Avalonia.Base.UnitTests/Media/GlyphRunTests.cs

@ -29,7 +29,7 @@ namespace Avalonia.Base.UnitTests.Media
} }
[InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 26.0, 0, 3, true)] [InlineData(new double[] { 30, 0, 0 }, new int[] { 0, 0, 0 }, 26.0, 0, 3, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 20.0, 1, 1, true)] [InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 20.0, 2, 0, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 26.0, 2, 1, true)] [InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 26.0, 2, 1, true)]
[InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 35.0, 2, 1, false)] [InlineData(new double[] { 10, 10, 10 }, new int[] { 0, 1, 2 }, 35.0, 2, 1, false)]
[Theory] [Theory]

BIN
tests/Avalonia.RenderTests/Assets/Manrope-Light.ttf

Binary file not shown.

99
tests/Avalonia.Skia.UnitTests/Media/GlyphRunTests.cs

@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
@ -221,6 +222,30 @@ namespace Avalonia.Skia.UnitTests.Media
} }
} }
[Fact]
public void Should_CharacterHit_From_Distance_Zero_Width()
{
const string df7Font = "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#DF7segHMI";
const string text = "3,47-=?:#";
using (Start())
{
var typeface = new Typeface(df7Font);
var options = new TextShaperOptions(typeface.GlyphTypeface, 14, 0);
var shapedBuffer = TextShaper.Current.ShapeText(text, options);
Assert.NotEmpty(shapedBuffer);
var firstGlyphInfo = shapedBuffer[0];
var glyphRun = CreateGlyphRun(shapedBuffer);
var characterHit = glyphRun.GetCharacterHitFromDistance(firstGlyphInfo.GlyphAdvance, out _);
Assert.Equal(2, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
}
}
[Fact] [Fact]
public void Should_Get_Distance_From_CharacterHit_Zero_Width() public void Should_Get_Distance_From_CharacterHit_Zero_Width()
{ {
@ -280,6 +305,80 @@ namespace Avalonia.Skia.UnitTests.Media
} }
} }
[Fact]
public void Should_Get_Distance_From_CharacterHit_Within_Cluster()
{
var text = "எடுத்துக்காட்டு வழி வினவல்";
using (Start())
{
var cp = Codepoint.ReadAt(text, 0, out _);
Assert.True(FontManager.Current.TryMatchCharacter(cp, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var typeface));
var options = new TextShaperOptions(typeface.GlyphTypeface, 12);
var shapedBuffer = TextShaper.Current.ShapeText(text, options);
var glyphRun = CreateGlyphRun(shapedBuffer);
var clusterWidth = new List<double>();
var distances = new List<double>();
var clusters = new List<int>();
var lastCluster = -1;
var currentDistance = 0.0;
var currentAdvance = 0.0;
foreach (var glyphInfo in shapedBuffer)
{
if (lastCluster != glyphInfo.GlyphCluster)
{
clusterWidth.Add(currentAdvance);
distances.Add(currentDistance);
clusters.Add(glyphInfo.GlyphCluster);
currentAdvance = 0;
}
lastCluster = glyphInfo.GlyphCluster;
currentDistance += glyphInfo.GlyphAdvance;
currentAdvance += glyphInfo.GlyphAdvance;
}
clusterWidth.RemoveAt(0);
clusterWidth.Add(currentAdvance);
var expectedLeftHit = new CharacterHit(11);
var distance = glyphRun.GetDistanceFromCharacterHit(expectedLeftHit);
var expectedLeft = distances[6];
Assert.Equal(expectedLeft, distance);
var leftHit = glyphRun.GetCharacterHitFromDistance(expectedLeft, out _);
Assert.Equal(11, leftHit.FirstCharacterIndex + leftHit.TrailingLength);
var expectedRight = distances[7];
distance = glyphRun.GetDistanceFromCharacterHit(new CharacterHit(12));
Assert.Equal(expectedRight, distance);
var expectedRightHit = new CharacterHit(13);
distance = glyphRun.GetDistanceFromCharacterHit(expectedRightHit);
Assert.Equal(expectedRight, distance);
var rightHit = glyphRun.GetCharacterHitFromDistance(expectedRight, out _);
Assert.Equal(13, rightHit.FirstCharacterIndex + rightHit.TrailingLength);
}
}
private static List<Rect> BuildRects(GlyphRun glyphRun) private static List<Rect> BuildRects(GlyphRun glyphRun)
{ {
var height = glyphRun.Bounds.Height; var height = glyphRun.Bounds.Height;

233
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -1639,7 +1639,6 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
[Fact] [Fact]
public void Should_GetTextBounds_NotInfiniteLoop() public void Should_GetTextBounds_NotInfiniteLoop()
{ {
@ -1868,6 +1867,238 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
} }
} }
[Fact]
public void Should_GetTextBounds_For_Multiple_TextRuns()
{
var text = "Test👩🏽‍🚒";
using (Start())
{
var typeface = Typeface.Default;
var defaultProperties = new GenericTextRunProperties(typeface, 12);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left,
true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0));
Assert.NotNull(textLine);
var result = textLine.GetTextBounds(0, 11);
Assert.Equal(1, result.Count);
var firstBounds = result[0];
Assert.NotEmpty(firstBounds.TextRunBounds);
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, firstBounds.Rectangle.Width, 2);
}
}
[Fact]
public void Should_GetTextBounds_Within_Cluster_2()
{
var text = "Test👩🏽‍🚒";
using (Start())
{
var typeface = Typeface.Default;
var defaultProperties = new GenericTextRunProperties(typeface, 12);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left,
true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0));
Assert.NotNull(textLine);
var textPosition = 0;
while(textPosition < text.Length)
{
var bounds = textLine.GetTextBounds(textPosition, 1);
Assert.Equal(1, bounds.Count);
var firstBounds = bounds[0];
Assert.Equal(1, firstBounds.TextRunBounds.Count);
var firstRunBounds = firstBounds.TextRunBounds[0];
Assert.Equal(textPosition, firstRunBounds.TextSourceCharacterIndex);
var expectedDistance = firstRunBounds.Rectangle.Left;
var characterHit = new CharacterHit(textPosition);
var distance = textLine.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(expectedDistance, distance, 2);
var nextCharacterHit = textLine.GetNextCaretCharacterHit(characterHit);
var expectedNextPosition = textPosition + firstRunBounds.Length;
var nextPosition = nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength;
Assert.Equal(expectedNextPosition, nextPosition);
var previousCharacterHit = textLine.GetPreviousCaretCharacterHit(nextCharacterHit);
Assert.Equal(characterHit, previousCharacterHit);
textPosition += firstRunBounds.Length;
}
}
}
[Fact]
public void Should_Get_TextBounds_With_Mixed_Runs_Within_Cluster()
{
using (Start())
{
const string manropeFont = "resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Manrope";
var typeface = new Typeface(manropeFont);
var defaultProperties = new GenericTextRunProperties(typeface);
var text = "Fotografin";
var shaperOption = new TextShaperOptions(typeface.GlyphTypeface);
var firstRun = new ShapedTextRun(TextShaper.Current.ShapeText(text, shaperOption), defaultProperties);
var textRuns = new List<TextRun>
{
new CustomDrawableRun(),
new CustomDrawableRun(),
firstRun,
new CustomDrawableRun(),
};
var textSource = new FixedRunsTextSource(textRuns);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
var textBounds = textLine.GetTextBounds(10, 1);
Assert.Equal(1, textBounds.Count);
var firstBounds = textBounds[0];
Assert.NotEmpty(firstBounds.TextRunBounds);
var firstRunBounds = firstBounds.TextRunBounds[0];
Assert.Equal(1, firstRunBounds.Length);
}
}
[Fact]
public void Should_Get_TextBounds_Tamil()
{
var text = "எடுத்துக்காட்டு வழி வினவல்";
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties, true);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(FlowDirection.LeftToRight, TextAlignment.Left,
true, true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0));
Assert.NotNull(textLine);
Assert.NotEmpty(textLine.TextRuns);
var firstRun = textLine.TextRuns[0] as ShapedTextRun;
Assert.NotNull(firstRun);
var clusterWidth = new List<double>();
var distances = new List<double>();
var clusters = new List<int>();
var lastCluster = -1;
var currentDistance = 0.0;
var currentAdvance = 0.0;
foreach (var glyphInfo in firstRun.ShapedBuffer)
{
if(lastCluster != glyphInfo.GlyphCluster)
{
clusterWidth.Add(currentAdvance);
distances.Add(currentDistance);
clusters.Add(glyphInfo.GlyphCluster);
currentAdvance = 0;
}
lastCluster = glyphInfo.GlyphCluster;
currentDistance += glyphInfo.GlyphAdvance;
currentAdvance += glyphInfo.GlyphAdvance;
}
clusterWidth.RemoveAt(0);
clusterWidth.Add(currentAdvance);
for (var i = 6; i < clusters.Count; i++)
{
var cluster = clusters[i];
var expectedDistance = distances[i];
var expectedWidth = clusterWidth[i];
var actualDistance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(expectedDistance, actualDistance, 2);
var characterHit = textLine.GetCharacterHitFromDistance(expectedDistance);
var textPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
Assert.Equal(cluster, textPosition);
var bounds = textLine.GetTextBounds(cluster, 1);
Assert.NotNull(bounds);
Assert.NotEmpty(bounds);
var firstBounds = bounds[0];
Assert.NotEmpty(firstBounds.TextRunBounds);
var firstRunBounds = firstBounds.TextRunBounds[0];
Assert.Equal(cluster, firstRunBounds.TextSourceCharacterIndex);
var width = firstRunBounds.Rectangle.Width;
Assert.Equal(expectedWidth, width, 2);
}
}
}
private class FixedRunsTextSource : ITextSource private class FixedRunsTextSource : ITextSource
{ {
private readonly IReadOnlyList<TextRun> _textRuns; private readonly IReadOnlyList<TextRun> _textRuns;

Loading…
Cancel
Save