Browse Source

Introduce TextRunProperties

pull/4101/head
Benedikt Schroeder 6 years ago
parent
commit
2a181d9acb
  1. 69
      samples/ControlCatalog/Pages/TextBlockPage.xaml
  2. 15
      src/Avalonia.Controls/Primitives/AccessText.cs
  3. 35
      src/Avalonia.Controls/TextBlock.cs
  4. 4
      src/Avalonia.Visuals/Media/FontManager.cs
  5. 112
      src/Avalonia.Visuals/Media/GlyphRun.cs
  6. 182
      src/Avalonia.Visuals/Media/TextDecoration.cs
  7. 17
      src/Avalonia.Visuals/Media/TextDecorationCollection.cs
  8. 2
      src/Avalonia.Visuals/Media/TextDecorationUnit.cs
  9. 6
      src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs
  10. 69
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs
  11. 40
      src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs
  12. 23
      src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs
  13. 164
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs
  14. 212
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs
  15. 395
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs
  16. 259
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs
  17. 181
      src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs
  18. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs
  19. 71
      src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs
  20. 148
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs
  21. 544
      src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs
  22. 9
      src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs
  23. 192
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  24. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  25. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs
  26. 235
      src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs
  27. 69
      src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs
  28. 19
      src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs
  29. 33
      src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs
  30. 16
      src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs
  31. 17
      src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs
  32. 90
      src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs
  33. 8
      src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs
  34. 39
      src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs
  35. 24
      src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs
  36. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs
  37. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs
  38. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs
  39. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs
  40. 2
      src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
  41. 9
      src/Avalonia.Visuals/Media/TextWrapping.cs
  42. 12
      src/Avalonia.Visuals/Media/Typeface.cs
  43. 12
      src/Avalonia.Visuals/Platform/ITextShaperImpl.cs
  44. 5
      src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs
  45. 30
      src/Avalonia.Visuals/Utilities/ValueSpan.cs
  46. 2
      src/Skia/Avalonia.Skia/FormattedTextImpl.cs
  47. 14
      src/Skia/Avalonia.Skia/TextShaperImpl.cs
  48. 15
      src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs
  49. 4
      tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs
  50. 2
      tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs
  51. 8
      tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs
  52. 4
      tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs
  53. 38
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs
  54. 36
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs
  55. 30
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs
  56. 275
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  57. 145
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  58. 175
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs
  59. 373
      tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs
  60. 19
      tests/Avalonia.UnitTests/MockTextShaperImpl.cs
  61. 2
      tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
  62. 2
      tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs
  63. 2
      tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

69
samples/ControlCatalog/Pages/TextBlockPage.xaml

@ -64,51 +64,42 @@
<TextDecorationCollection>
<TextDecoration
Location="Overline"
PenThicknessUnit="Pixel">
<TextDecoration.Pen>
<Pen Thickness="2">
<Pen.Brush>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Red"/>
<GradientStop Offset="1" Color="Green"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
StrokeThicknessUnit="Pixel"
StrokeThickness="2">
<TextDecoration.Stroke>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Red"/>
<GradientStop Offset="1" Color="Green"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</TextDecoration.Stroke>
</TextDecoration>
<TextDecoration
Location="Strikethrough"
PenThicknessUnit="Pixel">
<TextDecoration.Pen>
<Pen Thickness="1">
<Pen.Brush>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Green"/>
<GradientStop Offset="1" Color="Blue"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
StrokeThicknessUnit="Pixel"
StrokeThickness="1">
<TextDecoration.Stroke>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Green"/>
<GradientStop Offset="1" Color="Blue"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</TextDecoration.Stroke>
</TextDecoration>
<TextDecoration
Location="Underline"
PenThicknessUnit="Pixel">
<TextDecoration.Pen>
<Pen Thickness="2">
<Pen.Brush>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Blue"/>
<GradientStop Offset="1" Color="Red"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</Pen.Brush>
</Pen>
</TextDecoration.Pen>
StrokeThicknessUnit="Pixel"
StrokeThickness="2">
<TextDecoration.Stroke>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<LinearGradientBrush.GradientStops>
<GradientStop Offset="0" Color="Blue"/>
<GradientStop Offset="1" Color="Red"/>
</LinearGradientBrush.GradientStops>
</LinearGradientBrush>
</TextDecoration.Stroke>
</TextDecoration>
</TextDecorationCollection>
</TextBlock.TextDecorations>

15
src/Avalonia.Controls/Primitives/AccessText.cs

@ -110,7 +110,7 @@ namespace Avalonia.Controls.Primitives
foreach (var textLine in TextLayout.TextLines)
{
if (textLine.Text.End < textPosition)
if (textLine.TextRange.End < textPosition)
{
currentY += textLine.LineMetrics.Size.Height;
@ -121,21 +121,22 @@ namespace Avalonia.Controls.Primitives
foreach (var textRun in textLine.TextRuns)
{
if (!(textRun is ShapedTextRun shapedRun))
if (!(textRun is ShapedTextCharacters shapedTextCharacters))
{
continue;
}
if (shapedRun.GlyphRun.Characters.End < textPosition)
if (shapedTextCharacters.GlyphRun.Characters.End < textPosition)
{
currentX += shapedRun.GlyphRun.Bounds.Width;
currentX += shapedTextCharacters.GlyphRun.Bounds.Width;
continue;
}
var characterHit = shapedRun.GlyphRun.FindNearestCharacterHit(textPosition, out var width);
var characterHit =
shapedTextCharacters.GlyphRun.FindNearestCharacterHit(textPosition, out var width);
var distance = shapedRun.GlyphRun.GetDistanceFromCharacterHit(characterHit);
var distance = shapedTextCharacters.GlyphRun.GetDistanceFromCharacterHit(characterHit);
currentX += distance - width;
@ -144,7 +145,7 @@ namespace Avalonia.Controls.Primitives
width = 0.0;
}
return new Rect(currentX, currentY, width, shapedRun.GlyphRun.Bounds.Height);
return new Rect(currentX, currentY, width, shapedTextCharacters.GlyphRun.Bounds.Height);
}
}

35
src/Avalonia.Controls/TextBlock.cs

@ -70,6 +70,15 @@ namespace Avalonia.Controls
Brushes.Black,
inherits: true);
/// <summary>
/// Defines the <see cref="LineHeight"/> property.
/// </summary>
public static readonly StyledProperty<double> LineHeightProperty =
AvaloniaProperty.Register<TextBlock, double>(
nameof(LineHeight),
double.NaN,
validate: IsValidLineHeight);
/// <summary>
/// Defines the <see cref="MaxLines"/> property.
/// </summary>
@ -122,19 +131,19 @@ namespace Avalonia.Controls
{
ClipToBoundsProperty.OverrideDefaultValue<TextBlock>(true);
AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty,
AffectsRender<TextBlock>(BackgroundProperty, ForegroundProperty,
TextAlignmentProperty, TextDecorationsProperty);
AffectsMeasure<TextBlock>(FontSizeProperty, FontWeightProperty,
FontStyleProperty, TextWrappingProperty, FontFamilyProperty,
TextTrimmingProperty, TextProperty, PaddingProperty);
AffectsMeasure<TextBlock>(FontSizeProperty, FontWeightProperty,
FontStyleProperty, TextWrappingProperty, FontFamilyProperty,
TextTrimmingProperty, TextProperty, PaddingProperty, LineHeightProperty, MaxLinesProperty);
Observable.Merge(TextProperty.Changed, ForegroundProperty.Changed,
TextAlignmentProperty.Changed, TextWrappingProperty.Changed,
TextTrimmingProperty.Changed, FontSizeProperty.Changed,
FontStyleProperty.Changed, FontWeightProperty.Changed,
FontFamilyProperty.Changed, TextDecorationsProperty.Changed,
PaddingProperty.Changed
PaddingProperty.Changed, MaxLinesProperty.Changed, LineHeightProperty.Changed
).AddClassHandler<TextBlock>((x, _) => x.InvalidateTextLayout());
}
@ -230,6 +239,15 @@ namespace Avalonia.Controls
set { SetValue(ForegroundProperty, value); }
}
/// <summary>
/// Gets or sets the height of each line of content.
/// </summary>
public double LineHeight
{
get => GetValue(LineHeightProperty);
set => SetValue(LineHeightProperty, value);
}
/// <summary>
/// Gets or sets the maximum number of text lines.
/// </summary>
@ -395,7 +413,7 @@ namespace Avalonia.Controls
var padding = Padding;
TextLayout?.Draw(context.PlatformImpl, new Point(padding.Left, padding.Top));
TextLayout?.Draw(context, new Point(padding.Left, padding.Top));
}
/// <summary>
@ -422,7 +440,8 @@ namespace Avalonia.Controls
TextDecorations,
constraint.Width,
constraint.Height,
MaxLines);
maxLines: MaxLines,
lineHeight: LineHeight);
}
/// <summary>
@ -471,5 +490,7 @@ namespace Avalonia.Controls
}
private static bool IsValidMaxLines(int maxLines) => maxLines >= 0;
private static bool IsValidLineHeight(double lineHeight) => double.IsNaN(lineHeight) || lineHeight > 0;
}
}

4
src/Avalonia.Visuals/Media/FontManager.cs

@ -100,7 +100,7 @@ namespace Avalonia.Media
return typeface;
}
typeface = new Typeface(fontFamily, fontWeight, fontStyle);
typeface = new Typeface(fontFamily, fontStyle, fontWeight);
if (_typefaceCache.TryAdd(key, typeface))
{
@ -143,7 +143,7 @@ namespace Avalonia.Media
}
var matchedTypeface = PlatformImpl.TryMatchCharacter(codepoint, fontWeight, fontStyle, fontFamily, culture, out var key) ?
_typefaceCache.GetOrAdd(key, new Typeface(key.FamilyName, key.Weight, key.Style)) :
_typefaceCache.GetOrAdd(key, new Typeface(key.FamilyName, key.Style, key.Weight)) :
null;
return matchedTypeface;

112
src/Avalonia.Visuals/Media/GlyphRun.cs

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using Avalonia.Platform;
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media
{
@ -205,13 +205,16 @@ namespace Avalonia.Media
var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex);
var currentCluster = _glyphClusters[glyphIndex];
if (characterHit.TrailingLength > 0)
if (!GlyphClusters.IsEmpty)
{
while (glyphIndex < _glyphClusters.Length && _glyphClusters[glyphIndex] == currentCluster)
var currentCluster = GlyphClusters[glyphIndex];
if (characterHit.TrailingLength > 0)
{
glyphIndex++;
while (glyphIndex < GlyphClusters.Length && GlyphClusters[glyphIndex] == currentCluster)
{
glyphIndex++;
}
}
}
@ -302,7 +305,7 @@ namespace Avalonia.Media
}
}
var characterHit = FindNearestCharacterHit(GlyphClusters[index], out var width);
var characterHit = FindNearestCharacterHit(GlyphClusters.IsEmpty ? index : GlyphClusters[index], out var width);
var offset = GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex));
@ -370,26 +373,31 @@ namespace Avalonia.Media
/// </returns>
public int FindGlyphIndex(int characterIndex)
{
if (GlyphClusters.IsEmpty)
{
return characterIndex;
}
if (IsLeftToRight)
{
if (characterIndex < _glyphClusters[0])
if (characterIndex < GlyphClusters[0])
{
return 0;
}
if (characterIndex > _glyphClusters[_glyphClusters.Length - 1])
if (characterIndex > GlyphClusters[GlyphClusters.Length - 1])
{
return _glyphClusters.End;
}
}
else
{
if (characterIndex < _glyphClusters[_glyphClusters.Length - 1])
if (characterIndex < GlyphClusters[GlyphClusters.Length - 1])
{
return _glyphClusters.End;
}
if (characterIndex > _glyphClusters[0])
if (characterIndex > GlyphClusters[0])
{
return 0;
}
@ -397,7 +405,7 @@ namespace Avalonia.Media
var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer;
var clusters = _glyphClusters.Buffer.Span;
var clusters = GlyphClusters.Buffer.Span;
// Find the start of the cluster at the character index.
var start = clusters.BinarySearch((ushort)characterIndex, comparer);
@ -418,9 +426,19 @@ namespace Avalonia.Media
}
}
while (start > 0 && clusters[start - 1] == clusters[start])
if (IsLeftToRight)
{
start--;
while (start > 0 && clusters[start - 1] == clusters[start])
{
start--;
}
}
else
{
while (start + 1 < clusters.Length && clusters[start + 1] == clusters[start])
{
start++;
}
}
return start;
@ -440,34 +458,74 @@ namespace Avalonia.Media
var start = FindGlyphIndex(index);
var currentCluster = _glyphClusters[start];
if (GlyphClusters.IsEmpty)
{
width = GetGlyphWidth(index);
return new CharacterHit(start, 1);
}
var trailingLength = 0;
var cluster = GlyphClusters[start];
while (start < _glyphClusters.Length && _glyphClusters[start] == currentCluster)
var nextCluster = cluster;
var currentIndex = start;
while (nextCluster == cluster)
{
if (GlyphAdvances.IsEmpty)
width += GetGlyphWidth(currentIndex);
if (IsLeftToRight)
{
var glyph = GlyphIndices[start];
currentIndex++;
width += GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
if (currentIndex == GlyphClusters.Length)
{
break;
}
}
else
{
width += GlyphAdvances[start];
currentIndex--;
if (currentIndex < 0)
{
break;
}
}
trailingLength++;
start++;
nextCluster = GlyphClusters[currentIndex];
}
int trailingLength;
if (nextCluster == cluster)
{
trailingLength = Characters.Start + Characters.Length - cluster;
}
else
{
trailingLength = nextCluster - cluster;
}
if (start == _glyphClusters.Length &&
currentCluster + trailingLength != Characters.Start + Characters.Length)
return new CharacterHit(cluster, trailingLength);
}
/// <summary>
/// Gets a glyph's width.
/// </summary>
/// <param name="index">The glyph index.</param>
/// <returns>The glyph's width.</returns>
private double GetGlyphWidth(int index)
{
if (GlyphAdvances.IsEmpty)
{
trailingLength = Characters.Start + Characters.Length - currentCluster;
var glyph = GlyphIndices[index];
return GlyphTypeface.GetGlyphAdvance(glyph) * Scale;
}
return new CharacterHit(currentCluster, trailingLength);
return GlyphAdvances[index];
}
/// <summary>

182
src/Avalonia.Visuals/Media/TextDecoration.cs

@ -1,4 +1,5 @@
using Avalonia.Media.Immutable;
using Avalonia.Collections;
using Avalonia.Media.TextFormatting;
namespace Avalonia.Media
{
@ -14,28 +15,52 @@ namespace Avalonia.Media
AvaloniaProperty.Register<TextDecoration, TextDecorationLocation>(nameof(Location));
/// <summary>
/// Defines the <see cref="Pen"/> property.
/// Defines the <see cref="Stroke"/> property.
/// </summary>
public static readonly StyledProperty<IPen> PenProperty =
AvaloniaProperty.Register<TextDecoration, IPen>(nameof(Pen));
public static readonly StyledProperty<IBrush> StrokeProperty =
AvaloniaProperty.Register<TextDecoration, IBrush>(nameof(Stroke));
/// <summary>
/// Defines the <see cref="PenThicknessUnit"/> property.
/// Defines the <see cref="StrokeThicknessUnit"/> property.
/// </summary>
public static readonly StyledProperty<TextDecorationUnit> PenThicknessUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenThicknessUnit));
public static readonly StyledProperty<TextDecorationUnit> StrokeThicknessUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(StrokeThicknessUnit));
/// <summary>
/// Defines the <see cref="PenOffset"/> property.
/// Defines the <see cref="StrokeDashArray"/> property.
/// </summary>
public static readonly StyledProperty<double> PenOffsetProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(PenOffset));
public static readonly StyledProperty<AvaloniaList<double>> StrokeDashArrayProperty =
AvaloniaProperty.Register<TextDecoration, AvaloniaList<double>>(nameof(StrokeDashArray));
/// <summary>
/// Defines the <see cref="PenOffsetUnit"/> property.
/// Defines the <see cref="StrokeDashOffset"/> property.
/// </summary>
public static readonly StyledProperty<TextDecorationUnit> PenOffsetUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(PenOffsetUnit));
public static readonly StyledProperty<double> StrokeDashOffsetProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeDashOffset));
/// <summary>
/// Defines the <see cref="StrokeThickness"/> property.
/// </summary>
public static readonly StyledProperty<double> StrokeThicknessProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeThickness), 1);
/// <summary>
/// Defines the <see cref="StrokeLineCap"/> property.
/// </summary>
public static readonly StyledProperty<PenLineCap> StrokeLineCapProperty =
AvaloniaProperty.Register<TextDecoration, PenLineCap>(nameof(StrokeLineCap));
/// <summary>
/// Defines the <see cref="StrokeOffset"/> property.
/// </summary>
public static readonly StyledProperty<double> StrokeOffsetProperty =
AvaloniaProperty.Register<TextDecoration, double>(nameof(StrokeOffset));
/// <summary>
/// Defines the <see cref="StrokeOffsetUnit"/> property.
/// </summary>
public static readonly StyledProperty<TextDecorationUnit> StrokeOffsetUnitProperty =
AvaloniaProperty.Register<TextDecoration, TextDecorationUnit>(nameof(StrokeOffsetUnit));
/// <summary>
/// Gets or sets the location.
@ -50,54 +75,139 @@ namespace Avalonia.Media
}
/// <summary>
/// Gets or sets the pen.
/// Gets or sets the <see cref="IBrush"/> that specifies how the <see cref="TextDecoration"/> is painted.
/// </summary>
/// <value>
/// The pen.
/// </value>
public IPen Pen
public IBrush Stroke
{
get { return GetValue(StrokeProperty); }
set { SetValue(StrokeProperty, value); }
}
/// <summary>
/// Gets the units in which the thickness of the <see cref="TextDecoration"/> is expressed.
/// </summary>
public TextDecorationUnit StrokeThicknessUnit
{
get => GetValue(StrokeThicknessUnitProperty);
set => SetValue(StrokeThicknessUnitProperty, value);
}
/// <summary>
/// Gets or sets a collection of <see cref="double"/> values that indicate the pattern of dashes and gaps
/// that is used to draw the <see cref="TextDecoration"/>.
/// </summary>
public AvaloniaList<double> StrokeDashArray
{
get { return GetValue(StrokeDashArrayProperty); }
set { SetValue(StrokeDashArrayProperty, value); }
}
/// <summary>
/// Gets or sets a value that specifies the distance within the dash pattern where a dash begins.
/// </summary>
public double StrokeDashOffset
{
get { return GetValue(StrokeDashOffsetProperty); }
set { SetValue(StrokeDashOffsetProperty, value); }
}
/// <summary>
/// Gets or sets the thickness of the <see cref="TextDecoration"/>.
/// </summary>
public double StrokeThickness
{
get => GetValue(PenProperty);
set => SetValue(PenProperty, value);
get { return GetValue(StrokeThicknessProperty); }
set { SetValue(StrokeThicknessProperty, value); }
}
/// <summary>
/// Gets the units in which the Thickness of the text decoration's <see cref="Pen"/> is expressed.
/// Gets or sets a <see cref="PenLineCap"/> enumeration value that describes the shape at the ends of a line.
/// </summary>
public TextDecorationUnit PenThicknessUnit
public PenLineCap StrokeLineCap
{
get => GetValue(PenThicknessUnitProperty);
set => SetValue(PenThicknessUnitProperty, value);
get { return GetValue(StrokeLineCapProperty); }
set { SetValue(StrokeLineCapProperty, value); }
}
/// <summary>
/// Gets or sets the pen offset.
/// The stroke's offset.
/// </summary>
/// <value>
/// The pen offset.
/// </value>
public double PenOffset
public double StrokeOffset
{
get => GetValue(PenOffsetProperty);
set => SetValue(PenOffsetProperty, value);
get => GetValue(StrokeOffsetProperty);
set => SetValue(StrokeOffsetProperty, value);
}
/// <summary>
/// Gets the units in which the <see cref="PenOffset"/> value is expressed.
/// Gets the units in which the <see cref="StrokeOffset"/> value is expressed.
/// </summary>
public TextDecorationUnit PenOffsetUnit
public TextDecorationUnit StrokeOffsetUnit
{
get => GetValue(PenOffsetUnitProperty);
set => SetValue(PenOffsetUnitProperty, value);
get => GetValue(StrokeOffsetUnitProperty);
set => SetValue(StrokeOffsetUnitProperty, value);
}
/// <summary>
/// Creates an immutable clone of the <see cref="TextDecoration"/>.
/// Draws the <see cref="TextDecoration"/> at given origin.
/// </summary>
/// <returns>The immutable clone.</returns>
public ImmutableTextDecoration ToImmutable()
/// <param name="drawingContext">The drawing context.</param>
/// <param name="shapedTextCharacters">The shaped characters that are decorated.</param>
/// <param name="origin">The origin.</param>
internal void Draw(DrawingContext drawingContext, ShapedTextCharacters shapedTextCharacters, Point origin)
{
return new ImmutableTextDecoration(Location, Pen?.ToImmutable(), PenThicknessUnit, PenOffset, PenOffsetUnit);
var fontRenderingEmSize = shapedTextCharacters.Properties.FontRenderingEmSize;
var fontMetrics = shapedTextCharacters.FontMetrics;
var thickness = StrokeThickness;
switch (StrokeThicknessUnit)
{
case TextDecorationUnit.FontRecommended:
switch (Location)
{
case TextDecorationLocation.Underline:
thickness = fontMetrics.UnderlineThickness;
break;
case TextDecorationLocation.Strikethrough:
thickness = fontMetrics.StrikethroughThickness;
break;
}
break;
case TextDecorationUnit.FontRenderingEmSize:
thickness = fontRenderingEmSize * thickness;
break;
}
switch (Location)
{
case TextDecorationLocation.Overline:
origin += new Point(0, fontMetrics.Ascent);
break;
case TextDecorationLocation.Strikethrough:
origin += new Point(0, -fontMetrics.StrikethroughPosition);
break;
case TextDecorationLocation.Underline:
origin += new Point(0, -fontMetrics.UnderlinePosition);
break;
}
switch (StrokeOffsetUnit)
{
case TextDecorationUnit.FontRenderingEmSize:
origin += new Point(0, StrokeOffset * fontRenderingEmSize);
break;
case TextDecorationUnit.Pixel:
origin += new Point(0, StrokeOffset);
break;
}
var pen = new Pen(Stroke ?? shapedTextCharacters.Properties.ForegroundBrush, thickness,
new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap);
drawingContext.DrawLine(pen, origin, origin + new Point(shapedTextCharacters.Bounds.Width, 0));
}
}
}

17
src/Avalonia.Visuals/Media/TextDecorationCollection.cs

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using Avalonia.Collections;
using Avalonia.Media.Immutable;
using Avalonia.Utilities;
namespace Avalonia.Media
@ -11,22 +10,6 @@ namespace Avalonia.Media
/// </summary>
public class TextDecorationCollection : AvaloniaList<TextDecoration>
{
/// <summary>
/// Creates an immutable clone of the <see cref="TextDecorationCollection"/>.
/// </summary>
/// <returns>The immutable clone.</returns>
public ImmutableTextDecoration[] ToImmutable()
{
var immutable = new ImmutableTextDecoration[Count];
for (var i = 0; i < Count; i++)
{
immutable[i] = this[i].ToImmutable();
}
return immutable;
}
/// <summary>
/// Parses a <see cref="TextDecorationCollection"/> string.
/// </summary>

2
src/Avalonia.Visuals/Media/TextDecorationUnit.cs

@ -1,7 +1,7 @@
namespace Avalonia.Media
{
/// <summary>
/// Specifies the unit type of either a <see cref="TextDecoration.PenOffset"/> or a <see cref="Pen"/> thickness value.
/// Specifies the unit type of either a <see cref="TextDecoration.StrokeOffset"/> or a <see cref="TextDecoration.StrokeThickness"/> value.
/// </summary>
public enum TextDecorationUnit
{

6
src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs

@ -1,6 +1,4 @@
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A text run that supports drawing content.
@ -17,6 +15,6 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="origin">The origin.</param>
public abstract void Draw(IDrawingContextImpl drawingContext, Point origin);
public abstract void Draw(DrawingContext drawingContext, Point origin);
}
}

69
src/Avalonia.Visuals/Media/TextFormatting/GenericTextParagraphProperties.cs

@ -0,0 +1,69 @@
namespace Avalonia.Media.TextFormatting
{
public class GenericTextParagraphProperties : TextParagraphProperties
{
private TextAlignment _textAlignment;
private TextWrapping _textWrapping;
private TextTrimming _textTrimming;
private double _lineHeight;
public GenericTextParagraphProperties(
TextRunProperties defaultTextRunProperties,
TextAlignment textAlignment = TextAlignment.Left,
TextWrapping textWrapping = TextWrapping.WrapWithOverflow,
TextTrimming textTrimming = TextTrimming.None,
double lineHeight = 0)
{
DefaultTextRunProperties = defaultTextRunProperties;
_textAlignment = textAlignment;
_textWrapping = textWrapping;
_textTrimming = textTrimming;
_lineHeight = lineHeight;
}
public override TextRunProperties DefaultTextRunProperties { get; }
public override TextAlignment TextAlignment => _textAlignment;
public override TextWrapping TextWrapping => _textWrapping;
public override TextTrimming TextTrimming => _textTrimming;
public override double LineHeight => _lineHeight;
/// <summary>
/// Set text alignment
/// </summary>
internal void SetTextAlignment(TextAlignment textAlignment)
{
_textAlignment = textAlignment;
}
/// <summary>
/// Set text wrap
/// </summary>
internal void SetTextWrapping(TextWrapping textWrapping)
{
_textWrapping = textWrapping;
}
/// <summary>
/// Set text trimming
/// </summary>
internal void SetTextTrimming(TextTrimming textTrimming)
{
_textTrimming = textTrimming;
}
/// <summary>
/// Set line height
/// </summary>
internal void SetLineHeight(double lineHeight)
{
_lineHeight = lineHeight;
}
}
}

40
src/Avalonia.Visuals/Media/TextFormatting/GenericTextRunProperties.cs

@ -0,0 +1,40 @@
using System.Globalization;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Generic implementation of TextRunProperties
/// </summary>
public class GenericTextRunProperties : TextRunProperties
{
public GenericTextRunProperties(Typeface typeface, double fontRenderingEmSize = 12,
TextDecorationCollection textDecorations = null, IBrush foregroundBrush = null, IBrush backgroundBrush = null,
CultureInfo cultureInfo = null)
{
Typeface = typeface;
FontRenderingEmSize = fontRenderingEmSize;
TextDecorations = textDecorations;
ForegroundBrush = foregroundBrush;
BackgroundBrush = backgroundBrush;
CultureInfo = cultureInfo;
}
/// <inheritdoc />
public override Typeface Typeface { get; }
/// <inheritdoc />
public override double FontRenderingEmSize { get; }
/// <inheritdoc />
public override TextDecorationCollection TextDecorations { get; }
/// <inheritdoc />
public override IBrush ForegroundBrush { get; }
/// <inheritdoc />
public override IBrush BackgroundBrush { get; }
/// <inheritdoc />
public override CultureInfo CultureInfo { get; }
}
}

23
src/Avalonia.Visuals/Media/TextFormatting/ShapeableTextCharacters.cs

@ -0,0 +1,23 @@
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A group of characters that can be shaped.
/// </summary>
public sealed class ShapeableTextCharacters : TextRun
{
public ShapeableTextCharacters(ReadOnlySlice<char> text, TextRunProperties properties)
{
TextSourceLength = text.Length;
Text = text;
Properties = properties;
}
public override int TextSourceLength { get; }
public override ReadOnlySlice<char> Text { get; }
public override TextRunProperties Properties { get; }
}
}

164
src/Avalonia.Visuals/Media/TextFormatting/ShapedTextCharacters.cs

@ -0,0 +1,164 @@
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A text run that holds shaped characters.
/// </summary>
public sealed class ShapedTextCharacters : DrawableTextRun
{
public ShapedTextCharacters(GlyphRun glyphRun, TextRunProperties properties)
{
Text = glyphRun.Characters;
Properties = properties;
TextSourceLength = Text.Length;
FontMetrics = new FontMetrics(Properties.Typeface, Properties.FontRenderingEmSize);
GlyphRun = glyphRun;
}
/// <inheritdoc/>
public override ReadOnlySlice<char> Text { get; }
/// <inheritdoc/>
public override TextRunProperties Properties { get; }
/// <inheritdoc/>
public override int TextSourceLength { get; }
/// <inheritdoc/>
public override Rect Bounds => GlyphRun.Bounds;
/// <summary>
/// Gets the font metrics.
/// </summary>
/// <value>
/// The font metrics.
/// </value>
public FontMetrics FontMetrics { get; }
/// <summary>
/// Gets the glyph run.
/// </summary>
/// <value>
/// The glyphs.
/// </value>
public GlyphRun GlyphRun { get; }
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin)
{
if (GlyphRun.GlyphIndices.Length == 0)
{
return;
}
if (Properties.Typeface == null)
{
return;
}
if (Properties.ForegroundBrush == null)
{
return;
}
if (Properties.BackgroundBrush != null)
{
drawingContext.DrawRectangle(Properties.BackgroundBrush, null,
new Rect(origin.X, origin.Y + FontMetrics.Ascent, Bounds.Width, Bounds.Height));
}
drawingContext.DrawGlyphRun(Properties.ForegroundBrush, GlyphRun, origin);
if (Properties.TextDecorations == null)
{
return;
}
foreach (var textDecoration in Properties.TextDecorations)
{
textDecoration.Draw(drawingContext, this, origin);
}
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
public SplitTextCharactersResult Split(int length)
{
var glyphCount = 0;
var firstCharacters = GlyphRun.Characters.Take(length);
var codepointEnumerator = new CodepointEnumerator(firstCharacters);
while (codepointEnumerator.MoveNext())
{
glyphCount++;
}
if (GlyphRun.Characters.Length == length)
{
return new SplitTextCharactersResult(this, null);
}
if (GlyphRun.GlyphIndices.Length == glyphCount)
{
return new SplitTextCharactersResult(this, null);
}
var firstGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Take(glyphCount),
GlyphRun.GlyphAdvances.Take(glyphCount),
GlyphRun.GlyphOffsets.Take(glyphCount),
GlyphRun.Characters.Take(length),
GlyphRun.GlyphClusters.Take(glyphCount));
var firstTextRun = new ShapedTextCharacters(firstGlyphRun, Properties);
var secondGlyphRun = new GlyphRun(
Properties.Typeface.GlyphTypeface,
Properties.FontRenderingEmSize,
GlyphRun.GlyphIndices.Skip(glyphCount),
GlyphRun.GlyphAdvances.Skip(glyphCount),
GlyphRun.GlyphOffsets.Skip(glyphCount),
GlyphRun.Characters.Skip(length),
GlyphRun.GlyphClusters.Skip(glyphCount));
var secondTextRun = new ShapedTextCharacters(secondGlyphRun, Properties);
return new SplitTextCharactersResult(firstTextRun, secondTextRun);
}
public readonly struct SplitTextCharactersResult
{
public SplitTextCharactersResult(ShapedTextCharacters first, ShapedTextCharacters second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text run.
/// </summary>
/// <value>
/// The first text run.
/// </value>
public ShapedTextCharacters First { get; }
/// <summary>
/// Gets the second text run.
/// </summary>
/// <value>
/// The second text run.
/// </value>
public ShapedTextCharacters Second { get; }
}
}
}

212
src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs

@ -1,212 +0,0 @@
using Avalonia.Media.Immutable;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// A text run that holds a shaped glyph run.
/// </summary>
public sealed class ShapedTextRun : DrawableTextRun
{
public ShapedTextRun(ReadOnlySlice<char> text, TextStyle style) : this(
TextShaper.Current.ShapeText(text, style.TextFormat), style)
{
}
public ShapedTextRun(GlyphRun glyphRun, TextStyle style)
{
Text = glyphRun.Characters;
Style = style;
GlyphRun = glyphRun;
}
/// <inheritdoc/>
public override Rect Bounds => GlyphRun.Bounds;
/// <summary>
/// Gets the glyph run.
/// </summary>
/// <value>
/// The glyphs.
/// </value>
public GlyphRun GlyphRun { get; }
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
if (GlyphRun.GlyphIndices.Length == 0)
{
return;
}
if (Style.TextFormat.Typeface == null)
{
return;
}
if (Style.Foreground == null)
{
return;
}
drawingContext.DrawGlyphRun(Style.Foreground, GlyphRun, origin);
if (Style.TextDecorations == null)
{
return;
}
foreach (var textDecoration in Style.TextDecorations)
{
DrawTextDecoration(drawingContext, textDecoration, origin);
}
}
/// <summary>
/// Draws the <see cref="TextDecoration"/> at given origin.
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="textDecoration">The text decoration.</param>
/// <param name="origin">The origin.</param>
private void DrawTextDecoration(IDrawingContextImpl drawingContext, ImmutableTextDecoration textDecoration, Point origin)
{
var textFormat = Style.TextFormat;
var fontMetrics = Style.TextFormat.FontMetrics;
var thickness = textDecoration.Pen?.Thickness ?? 1.0;
switch (textDecoration.PenThicknessUnit)
{
case TextDecorationUnit.FontRecommended:
switch (textDecoration.Location)
{
case TextDecorationLocation.Underline:
thickness = fontMetrics.UnderlineThickness;
break;
case TextDecorationLocation.Strikethrough:
thickness = fontMetrics.StrikethroughThickness;
break;
}
break;
case TextDecorationUnit.FontRenderingEmSize:
thickness = textFormat.FontRenderingEmSize * thickness;
break;
}
switch (textDecoration.Location)
{
case TextDecorationLocation.Overline:
origin += new Point(0, textFormat.FontMetrics.Ascent);
break;
case TextDecorationLocation.Strikethrough:
origin += new Point(0, -textFormat.FontMetrics.StrikethroughPosition);
break;
case TextDecorationLocation.Underline:
origin += new Point(0, -textFormat.FontMetrics.UnderlinePosition);
break;
}
switch (textDecoration.PenOffsetUnit)
{
case TextDecorationUnit.FontRenderingEmSize:
origin += new Point(0, textDecoration.PenOffset * textFormat.FontRenderingEmSize);
break;
case TextDecorationUnit.Pixel:
origin += new Point(0, textDecoration.PenOffset);
break;
}
var pen = new ImmutablePen(
textDecoration.Pen?.Brush ?? Style.Foreground.ToImmutable(),
thickness,
textDecoration.Pen?.DashStyle?.ToImmutable(),
textDecoration.Pen?.LineCap ?? default,
textDecoration.Pen?.LineJoin ?? PenLineJoin.Miter,
textDecoration.Pen?.MiterLimit ?? 10.0);
drawingContext.DrawLine(pen, origin, origin + new Point(GlyphRun.Bounds.Width, 0));
}
/// <summary>
/// Splits the <see cref="TextRun"/> at specified length.
/// </summary>
/// <param name="length">The length.</param>
/// <returns>The split result.</returns>
public SplitTextCharactersResult Split(int length)
{
var glyphCount = 0;
var firstCharacters = GlyphRun.Characters.Take(length);
var codepointEnumerator = new CodepointEnumerator(firstCharacters);
while (codepointEnumerator.MoveNext())
{
glyphCount++;
}
if (GlyphRun.Characters.Length == length)
{
return new SplitTextCharactersResult(this, null);
}
if (GlyphRun.GlyphIndices.Length == glyphCount)
{
return new SplitTextCharactersResult(this, null);
}
var firstGlyphRun = new GlyphRun(
Style.TextFormat.Typeface.GlyphTypeface,
Style.TextFormat.FontRenderingEmSize,
GlyphRun.GlyphIndices.Take(glyphCount),
GlyphRun.GlyphAdvances.Take(glyphCount),
GlyphRun.GlyphOffsets.Take(glyphCount),
GlyphRun.Characters.Take(length),
GlyphRun.GlyphClusters.Take(length));
var firstTextRun = new ShapedTextRun(firstGlyphRun, Style);
var secondGlyphRun = new GlyphRun(
Style.TextFormat.Typeface.GlyphTypeface,
Style.TextFormat.FontRenderingEmSize,
GlyphRun.GlyphIndices.Skip(glyphCount),
GlyphRun.GlyphAdvances.Skip(glyphCount),
GlyphRun.GlyphOffsets.Skip(glyphCount),
GlyphRun.Characters.Skip(length),
GlyphRun.GlyphClusters.Skip(length));
var secondTextRun = new ShapedTextRun(secondGlyphRun, Style);
return new SplitTextCharactersResult(firstTextRun, secondTextRun);
}
public readonly struct SplitTextCharactersResult
{
public SplitTextCharactersResult(ShapedTextRun first, ShapedTextRun second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text run.
/// </summary>
/// <value>
/// The first text run.
/// </value>
public ShapedTextRun First { get; }
/// <summary>
/// Gets the second text run.
/// </summary>
/// <value>
/// The second text run.
/// </value>
public ShapedTextRun Second { get; }
}
}
}

395
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs

@ -1,395 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting
{
internal class SimpleTextFormatter : TextFormatter
{
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <inheritdoc cref="TextFormatter.FormatLine"/>
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties)
{
var textTrimming = paragraphProperties.TextTrimming;
var textWrapping = paragraphProperties.TextWrapping;
TextLine textLine;
var textRuns = FormatTextRuns(textSource, firstTextSourceIndex, out var textPointer);
if (textTrimming != TextTrimming.None)
{
textLine = PerformTextTrimming(textPointer, textRuns, paragraphWidth, paragraphProperties);
}
else
{
if (textWrapping == TextWrapping.Wrap)
{
textLine = PerformTextWrapping(textPointer, textRuns, paragraphWidth, paragraphProperties);
}
else
{
var textLineMetrics =
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment);
textLine = new SimpleTextLine(textPointer, textRuns, textLineMetrics);
}
}
return textLine;
}
/// <summary>
/// Formats text runs with optional text style overrides.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="textPointer">The text pointer that covers the formatted text runs.</param>
/// <returns>
/// The formatted text runs.
/// </returns>
private List<ShapedTextRun> FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer)
{
var start = -1;
var length = 0;
var textRuns = new List<ShapedTextRun>();
while (true)
{
var textRun = textSource.GetTextRun(firstTextSourceIndex + length);
if (start == -1)
{
start = textRun.Text.Start;
}
if (textRun is TextEndOfLine)
{
break;
}
switch (textRun)
{
case TextCharacters textCharacters:
var runText = textCharacters.Text;
while (!runText.IsEmpty)
{
var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style);
var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length),
shapableTextStyleRun.Style);
textRuns.Add(shapedRun);
runText = runText.Skip(shapedRun.Text.Length);
}
break;
default:
throw new NotSupportedException("Run type not supported by the formatter.");
}
length += textRun.Text.Length;
}
textPointer = new TextPointer(start, length);
return textRuns;
}
/// <summary>
/// Performs text trimming and returns a trimmed line.
/// </summary>
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <param name="textRuns">The text runs to perform the trimming on.</param>
/// <param name="text">The text that was used to construct the text runs.</param>
/// <returns></returns>
private static TextLine PerformTextTrimming(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var textTrimming = paragraphProperties.TextTrimming;
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
currentWidth += currentRun.GlyphRun.Bounds.Width;
if (currentWidth > availableWidth)
{
var ellipsisRun = CreateEllipsisRun(currentRun.Style);
var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width);
if (textTrimming == TextTrimming.WordEllipsis)
{
if (measuredLength < text.End)
{
var currentBreakPosition = 0;
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{
var nextBreakPosition = lineBreaker.Current.PositionWrap;
if (nextBreakPosition == 0)
{
break;
}
if (nextBreakPosition > measuredLength)
{
break;
}
currentBreakPosition = nextBreakPosition;
}
measuredLength = currentBreakPosition;
}
}
var splitResult = SplitTextRuns(textRuns, measuredLength);
var trimmedRuns = new List<ShapedTextRun>(splitResult.First.Count + 1);
trimmedRuns.AddRange(splitResult.First);
trimmedRuns.Add(ellipsisRun);
var textLineMetrics =
TextLineMetrics.Create(trimmedRuns, paragraphWidth, paragraphProperties.TextAlignment);
return new SimpleTextLine(text.Take(measuredLength), trimmedRuns, textLineMetrics);
}
availableWidth -= currentRun.GlyphRun.Bounds.Width;
runIndex++;
}
return new SimpleTextLine(text, textRuns,
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment));
}
/// <summary>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <param name="textRuns">The text run'S.</param>
/// <param name="text">The text to analyze for break opportunities.</param>
/// <param name="paragraphWidth"></param>
/// <returns></returns>
private static TextLine PerformTextWrapping(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
var length = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth)
{
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth);
if (measuredLength < currentRun.Text.Length)
{
var currentBreakPosition = -1;
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{
var nextBreakPosition = lineBreaker.Current.PositionWrap;
if (nextBreakPosition == 0)
{
break;
}
if (nextBreakPosition > measuredLength)
{
break;
}
currentBreakPosition = nextBreakPosition;
}
if (currentBreakPosition != -1)
{
measuredLength = currentBreakPosition;
}
}
length += measuredLength;
var splitResult = SplitTextRuns(textRuns, length);
var textLineMetrics =
TextLineMetrics.Create(splitResult.First, paragraphWidth, paragraphProperties.TextAlignment);
return new SimpleTextLine(text.Take(length), splitResult.First, textLineMetrics);
}
currentWidth += currentRun.GlyphRun.Bounds.Width;
length += currentRun.GlyphRun.Characters.Length;
runIndex++;
}
return new SimpleTextLine(text, textRuns,
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment));
}
/// <summary>
/// Measures the number of characters that fits into available width.
/// </summary>
/// <param name="textRun">The text run.</param>
/// <param name="availableWidth">The available width.</param>
/// <returns></returns>
private static int MeasureText(ShapedTextRun textRun, double availableWidth)
{
var glyphRun = textRun.GlyphRun;
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _);
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textRun.Text.Start;
}
/// <summary>
/// Creates an ellipsis.
/// </summary>
/// <param name="textStyle">The text style.</param>
/// <returns></returns>
private static ShapedTextRun CreateEllipsisRun(TextStyle textStyle)
{
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>();
var glyphRun = formatterImpl.ShapeText(s_ellipsis, textStyle.TextFormat);
return new ShapedTextRun(glyphRun, textStyle);
}
private readonly struct SplitTextRunsResult
{
public SplitTextRunsResult(IReadOnlyList<ShapedTextRun> first, IReadOnlyList<ShapedTextRun> second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text runs.
/// </summary>
/// <value>
/// The first text runs.
/// </value>
public IReadOnlyList<ShapedTextRun> First { get; }
/// <summary>
/// Gets the second text runs.
/// </summary>
/// <value>
/// The second text runs.
/// </value>
public IReadOnlyList<ShapedTextRun> Second { get; }
}
/// <summary>
/// Split a sequence of runs into two segments at specified length.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param>
/// <returns></returns>
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextRun> textRuns, int length)
{
var currentLength = 0;
for (var i = 0; i < textRuns.Count; i++)
{
var currentRun = textRuns[i];
if (currentLength + currentRun.GlyphRun.Characters.Length < length)
{
currentLength += currentRun.GlyphRun.Characters.Length;
continue;
}
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i;
var first = new ShapedTextRun[firstCount];
if (firstCount > 1)
{
for (var j = 0; j < i; j++)
{
first[j] = textRuns[j];
}
}
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.GlyphRun.Characters.Length == length)
{
var second = new ShapedTextRun[secondCount];
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0;
if (secondCount > 0)
{
for (var j = 0; j < secondCount; j++)
{
second[j] = textRuns[i + j + offset];
}
}
first[i] = currentRun;
return new SplitTextRunsResult(first, second);
}
else
{
secondCount++;
var second = new ShapedTextRun[secondCount];
if (secondCount > 0)
{
for (var j = 1; j < secondCount; j++)
{
second[j] = textRuns[i + j];
}
}
var split = currentRun.Split(length - currentLength);
first[i] = split.First;
second[0] = split.Second;
return new SplitTextRunsResult(first, second);
}
}
return new SplitTextRunsResult(textRuns, null);
}
}
}

259
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs

@ -1,259 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting
{
internal class SimpleTextLine : TextLine
{
private readonly IReadOnlyList<ShapedTextRun> _textRuns;
public SimpleTextLine(TextPointer textPointer, IReadOnlyList<ShapedTextRun> textRuns, TextLineMetrics lineMetrics)
{
Text = textPointer;
_textRuns = textRuns;
LineMetrics = lineMetrics;
}
/// <inheritdoc/>
public override TextPointer Text { get; }
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextLineMetrics LineMetrics { get; }
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
var currentX = origin.X;
foreach (var textRun in _textRuns)
{
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X,
origin.Y + LineMetrics.BaselineOrigin.Y);
textRun.Draw(drawingContext, baselineOrigin);
currentX += textRun.Bounds.Width;
}
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
if (distance < 0)
{
// hit happens before the line, return the first position
return new CharacterHit(Text.Start);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
foreach (var run in _textRuns)
{
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
if (distance <= run.Bounds.Width)
{
break;
}
distance -= run.Bounds.Width;
}
return characterHit;
}
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
}
/// <inheritdoc/>
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
{
int nextVisibleCp;
bool navigableCpFound;
if (characterHit.TrailingLength == 0)
{
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp);
if (navigableCpFound)
{
// Move from leading to trailing edge
return new CharacterHit(nextVisibleCp, 1);
}
}
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp);
if (navigableCpFound)
{
// Move from trailing edge of current character to trailing edge of next
return new CharacterHit(nextVisibleCp, 1);
}
// Can't move, we're after the last character
return characterHit;
}
/// <inheritdoc/>
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
{
int previousCodepointIndex;
bool codepointIndexFound;
var cpHit = characterHit.FirstCharacterIndex;
var trailingHit = characterHit.TrailingLength != 0;
// Input can be right after the end of the current line. Snap it to be at the end of the line.
if (cpHit >= Text.Start + Text.Length)
{
cpHit = Text.Start + Text.Length - 1;
trailingHit = true;
}
if (trailingHit)
{
codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex);
if (codepointIndexFound)
{
// Move from trailing to leading edge
return new CharacterHit(previousCodepointIndex, 0);
}
}
codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex);
if (codepointIndexFound)
{
// Move from leading edge of current character to leading edge of previous
return new CharacterHit(previousCodepointIndex, 0);
}
// Can't move, we're before the first character
return characterHit;
}
/// <inheritdoc/>
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit)
{
// same operation as move-to-previous
return GetPreviousCaretCharacterHit(characterHit);
}
/// <summary>
/// Get distance from line start to the specified codepoint index
/// </summary>
private double DistanceFromCodepointIndex(int codepointIndex)
{
var currentDistance = 0.0;
foreach (var textRun in _textRuns)
{
if (codepointIndex > textRun.Text.End)
{
currentDistance += textRun.Bounds.Width;
continue;
}
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex));
}
return currentDistance;
}
/// <summary>
/// Search forward from the given codepoint index (inclusive) to find the next navigable codepoint index.
/// Return true if one such codepoint index is found, false otherwise.
/// </summary>
private bool FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex)
{
nextCodepointIndex = codepointIndex;
if (codepointIndex >= Text.Start + Text.Length)
{
return false; // Cannot go forward anymore
}
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart);
while (runIndex < TextRuns.Count)
{
// When navigating forward, only the trailing edge of visible content is
// navigable.
if (runIndex < TextRuns.Count)
{
nextCodepointIndex = Math.Max(cpRunStart, codepointIndex);
return true;
}
cpRunStart += TextRuns[runIndex++].Text.Length;
}
return false;
}
/// <summary>
/// Search backward from the given codepoint index (inclusive) to find the previous navigable codepoint index.
/// Return true if one such codepoint is found, false otherwise.
/// </summary>
private bool FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex)
{
previousCodepointIndex = codepointIndex;
if (codepointIndex < Text.Start)
{
return false; // Cannot go backward anymore.
}
// Position the cpRunEnd at the end of the span that contains the given cp
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd);
codepointIndexAtRunEnd += TextRuns[runIndex].Text.End;
while (runIndex >= 0)
{
// Visible content has caret stops at its leading edge.
if (runIndex + 1 < TextRuns.Count)
{
previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex);
return true;
}
// Newline sequence has caret stops at its leading edge.
if (runIndex == TextRuns.Count)
{
// Get the cp index at the beginning of the newline sequence.
previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1;
return true;
}
codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length;
}
return false;
}
private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart)
{
codepointIndexAtRunStart = Text.Start;
runIndex = 0;
// Find the span that contains the given cp
while (runIndex < TextRuns.Count &&
codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex)
{
codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length;
}
}
}
}

181
src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs

@ -1,4 +1,6 @@
using Avalonia.Utility;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -7,15 +9,182 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public class TextCharacters : TextRun
{
protected TextCharacters()
public TextCharacters(ReadOnlySlice<char> text, TextRunProperties properties)
{
TextSourceLength = text.Length;
Text = text;
Properties = properties;
}
public TextCharacters(ReadOnlySlice<char> text, TextStyle style)
/// <inheritdoc />
public override int TextSourceLength { get; }
/// <inheritdoc />
public override ReadOnlySlice<char> Text { get; }
/// <inheritdoc />
public override TextRunProperties Properties { get; }
/// <summary>
/// Gets a list of <see cref="ShapeableTextCharacters"/>.
/// </summary>
/// <returns>The shapeable text characters.</returns>
internal IList<ShapeableTextCharacters> GetShapeableCharacters()
{
Text = text;
Style = style;
var shapeableCharacters = new List<ShapeableTextCharacters>(2);
var runText = Text;
while (!runText.IsEmpty)
{
var shapeableRun = CreateShapeableRun(runText, Properties);
shapeableCharacters.Add(shapeableRun);
runText = runText.Skip(shapeableRun.Text.Length);
}
return shapeableCharacters;
}
/// <summary>
/// Creates a shapeable text run with unique properties.
/// </summary>
/// <param name="text">The text to create text runs from.</param>
/// <param name="defaultProperties">The default text run properties.</param>
/// <returns>A list of shapeable text runs.</returns>
private ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice<char> text, TextRunProperties defaultProperties)
{
var defaultTypeface = defaultProperties.Typeface;
var currentTypeface = defaultTypeface;
if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count))
{
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
var codepoint = Codepoint.ReadAt(text, count, out _);
//ToDo: Fix FontFamily fallback
currentTypeface =
FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style, defaultTypeface.FontFamily);
if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count))
{
//Fallback found
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
// no fallback found
currentTypeface = defaultTypeface;
var glyphTypeface = currentTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
if (!grapheme.FirstCodepoint.IsWhiteSpace && glyphTypeface.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
count += grapheme.Text.Length;
}
return new ShapeableTextCharacters(text.Take(count),
new GenericTextRunProperties(currentTypeface, defaultProperties.FontRenderingEmSize,
defaultProperties.TextDecorations, defaultProperties.ForegroundBrush));
}
/// <summary>
/// Tries to get run properties.
/// </summary>
/// <param name="defaultTypeface"></param>
/// <param name="text"></param>
/// <param name="typeface">The typeface that is used to find matching characters.</param>
/// <param name="count"></param>
/// <returns></returns>
protected bool TryGetRunProperties(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int count)
{
if (text.Length == 0)
{
count = 0;
return false;
}
var isFallback = typeface != defaultTypeface;
count = 0;
var script = Script.Common;
//var direction = BiDiClass.LeftToRight;
var font = typeface.GlyphTypeface;
var defaultFont = defaultTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
var currentScript = grapheme.FirstCodepoint.Script;
//var currentDirection = grapheme.FirstCodepoint.BiDiClass;
//// ToDo: Implement BiDi algorithm
//if (currentScript.HorizontalDirection != direction)
//{
// if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint))
// {
// break;
// }
//}
if (currentScript != script)
{
if (currentScript != Script.Inherited && currentScript != Script.Common)
{
if (script == Script.Inherited || script == Script.Common)
{
script = currentScript;
}
else
{
break;
}
}
}
if (isFallback)
{
if (defaultFont.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
}
if (!font.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
if (!grapheme.FirstCodepoint.IsWhiteSpace)
{
break;
}
}
count += grapheme.Text.Length;
}
return count > 0;
}
}
}

17
src/Avalonia.Visuals/Media/TextFormatting/TextEndOfSegment.cs

@ -0,0 +1,17 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Specialized text run used to mark the end of a segment, i.e., to end
/// the scope affected by a preceding TextModifier run.
/// </summary>
public class TextEndOfSegment : TextRun
{
public TextEndOfSegment(int textSourceLength)
{
TextSourceLength = textSourceLength;
}
/// <inheritdoc />
public override int TextSourceLength { get; }
}
}

71
src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs

@ -1,71 +0,0 @@
using System;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Unique text formatting properties that are used by the <see cref="TextFormatter"/>.
/// </summary>
public readonly struct TextFormat : IEquatable<TextFormat>
{
public TextFormat(Typeface typeface, double fontRenderingEmSize)
{
Typeface = typeface;
FontRenderingEmSize = fontRenderingEmSize;
FontMetrics = new FontMetrics(typeface, fontRenderingEmSize);
}
/// <summary>
/// Gets the typeface.
/// </summary>
/// <value>
/// The typeface.
/// </value>
public Typeface Typeface { get; }
/// <summary>
/// Gets the font rendering em size.
/// </summary>
/// <value>
/// The em rendering size of the font.
/// </value>
public double FontRenderingEmSize { get; }
/// <summary>
/// Gets the font metrics.
/// </summary>
/// <value>
/// The metrics of the font.
/// </value>
public FontMetrics FontMetrics { get; }
public static bool operator ==(TextFormat self, TextFormat other)
{
return self.Equals(other);
}
public static bool operator !=(TextFormat self, TextFormat other)
{
return !(self == other);
}
public bool Equals(TextFormat other)
{
return Typeface.Equals(other.Typeface) && FontRenderingEmSize.Equals(other.FontRenderingEmSize);
}
public override bool Equals(object obj)
{
return obj is TextFormat other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode();
return hashCode;
}
}
}
}

148
src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs

@ -1,5 +1,4 @@
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utility;
namespace Avalonia.Media.TextFormatting
{
@ -22,7 +21,7 @@ namespace Avalonia.Media.TextFormatting
return current;
}
current = new SimpleTextFormatter();
current = new TextFormatterImpl();
AvaloniaLocator.CurrentMutable.Bind<TextFormatter>().ToConstant(current);
@ -38,149 +37,10 @@ namespace Avalonia.Media.TextFormatting
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <param name="previousLineBreak">A <see cref="TextLineBreak"/> value that specifies the text formatter state,
/// in terms of where the previous line in the paragraph was broken by the text formatting process.</param>
/// <returns>The formatted line.</returns>
public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties);
/// <summary>
/// Creates a text style run with unique properties.
/// </summary>
/// <param name="text">The text to create text runs from.</param>
/// <param name="defaultStyle"></param>
/// <returns>A list of text runs.</returns>
protected TextStyleRun CreateShapableTextStyleRun(ReadOnlySlice<char> text, TextStyle defaultStyle)
{
var defaultTypeface = defaultStyle.TextFormat.Typeface;
var currentTypeface = defaultTypeface;
if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count))
{
return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface,
defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
var codepoint = Codepoint.ReadAt(text, count, out _);
//ToDo: Fix FontFamily fallback
currentTypeface =
FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style, defaultStyle.TextFormat.Typeface.FontFamily);
if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count))
{
//Fallback found
return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface,
defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
// no fallback found
currentTypeface = defaultTypeface;
var glyphTypeface = currentTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
if (!grapheme.FirstCodepoint.IsWhiteSpace && glyphTypeface.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
count += grapheme.Text.Length;
}
return new TextStyleRun(new TextPointer(text.Start, count),
new TextStyle(currentTypeface, defaultStyle.TextFormat.FontRenderingEmSize,
defaultStyle.Foreground, defaultStyle.TextDecorations));
}
/// <summary>
/// Tries to get run properties.
/// </summary>
/// <param name="defaultTypeface"></param>
/// <param name="text"></param>
/// <param name="typeface">The typeface that is used to find matching characters.</param>
/// <param name="count"></param>
/// <returns></returns>
protected bool TryGetRunProperties(ReadOnlySlice<char> text, Typeface typeface, Typeface defaultTypeface,
out int count)
{
if (text.Length == 0)
{
count = 0;
return false;
}
var isFallback = typeface != defaultTypeface;
count = 0;
var script = Script.Common;
//var direction = BiDiClass.LeftToRight;
var font = typeface.GlyphTypeface;
var defaultFont = defaultTypeface.GlyphTypeface;
var enumerator = new GraphemeEnumerator(text);
while (enumerator.MoveNext())
{
var grapheme = enumerator.Current;
var currentScript = grapheme.FirstCodepoint.Script;
//var currentDirection = grapheme.FirstCodepoint.BiDiClass;
//// ToDo: Implement BiDi algorithm
//if (currentScript.HorizontalDirection != direction)
//{
// if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint))
// {
// break;
// }
//}
if (currentScript != script)
{
if (currentScript != Script.Inherited && currentScript != Script.Common)
{
if (script == Script.Inherited || script == Script.Common)
{
script = currentScript;
}
else
{
break;
}
}
}
if (isFallback)
{
if (defaultFont.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
break;
}
}
if (!font.TryGetGlyph(grapheme.FirstCodepoint, out _))
{
if (!grapheme.FirstCodepoint.IsWhiteSpace)
{
break;
}
}
count += grapheme.Text.Length;
}
return count > 0;
}
TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null);
}
}

544
src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs

@ -0,0 +1,544 @@
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
internal class TextFormatterImpl : TextFormatter
{
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' });
/// <inheritdoc cref="TextFormatter.FormatLine"/>
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null)
{
var textTrimming = paragraphProperties.TextTrimming;
var textWrapping = paragraphProperties.TextWrapping;
TextLine textLine = null;
var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak);
var textRange = GetTextRange(textRuns);
if (textTrimming != TextTrimming.None)
{
textLine = PerformTextTrimming(textRuns, textRange, paragraphWidth, paragraphProperties);
}
else
{
switch (textWrapping)
{
case TextWrapping.NoWrap:
{
var textLineMetrics =
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties);
textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak);
break;
}
case TextWrapping.WrapWithOverflow:
case TextWrapping.Wrap:
{
textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties);
break;
}
}
}
return textLine;
}
/// <summary>
/// Fetches text runs.
/// </summary>
/// <param name="textSource">The text source.</param>
/// <param name="firstTextSourceIndex">The first text source index.</param>
/// <param name="previousLineBreak">Previous line break. Can be null.</param>
/// <param name="nextLineBreak">Next line break. Can be null.</param>
/// <returns>
/// The formatted text runs.
/// </returns>
private static IReadOnlyList<ShapedTextCharacters> FetchTextRuns(ITextSource textSource,
int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak)
{
nextLineBreak = default;
var currentLength = 0;
var textRuns = new List<ShapedTextCharacters>();
if (previousLineBreak != null)
{
foreach (var shapedCharacters in previousLineBreak.RemainingCharacters)
{
textRuns.Add(shapedCharacters);
if (TryGetLineBreak(shapedCharacters, out var runLineBreak))
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
nextLineBreak = new TextLineBreak(splitResult.Second);
return splitResult.First;
}
currentLength += shapedCharacters.Text.Length;
}
}
firstTextSourceIndex += currentLength;
var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex);
while (textRunEnumerator.MoveNext())
{
var textRun = textRunEnumerator.Current;
switch (textRun)
{
case TextCharacters textCharacters:
{
var shapeableRuns = textCharacters.GetShapeableCharacters();
foreach (var run in shapeableRuns)
{
var glyphRun = TextShaper.Current.ShapeText(run.Text, run.Properties.Typeface,
run.Properties.FontRenderingEmSize, run.Properties.CultureInfo);
var shapedCharacters = new ShapedTextCharacters(glyphRun, textRun.Properties);
textRuns.Add(shapedCharacters);
}
break;
}
}
if (TryGetLineBreak(textRun, out var runLineBreak))
{
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap);
nextLineBreak = new TextLineBreak(splitResult.Second);
return splitResult.First;
}
currentLength += textRun.Text.Length;
}
return textRuns;
}
private static bool TryGetLineBreak(TextRun textRun, out LineBreak lineBreak)
{
lineBreak = default;
if (textRun.Text.IsEmpty)
{
return false;
}
var lineBreakEnumerator = new LineBreakEnumerator(textRun.Text);
while (lineBreakEnumerator.MoveNext())
{
if (!lineBreakEnumerator.Current.Required)
{
continue;
}
lineBreak = lineBreakEnumerator.Current;
if (lineBreak.PositionWrap >= textRun.Text.Length)
{
return true;
}
//The line breaker isn't treating \n\r as a pair so we have to fix that here.
if (textRun.Text[lineBreak.PositionMeasure] == '\n'
&& textRun.Text[lineBreak.PositionWrap] == '\r')
{
lineBreak = new LineBreak(lineBreak.PositionMeasure, lineBreak.PositionWrap + 1,
lineBreak.Required);
}
return true;
}
return false;
}
/// <summary>
/// Performs text trimming and returns a trimmed line.
/// </summary>
/// <param name="textRuns">The text runs to perform the trimming on.</param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
/// <returns></returns>
private static TextLine PerformTextTrimming(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var textTrimming = paragraphProperties.TextTrimming;
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
currentWidth += currentRun.GlyphRun.Bounds.Width;
if (currentWidth > availableWidth)
{
var ellipsisRun = CreateEllipsisRun(currentRun.Properties);
var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width);
if (textTrimming == TextTrimming.WordEllipsis)
{
if (measuredLength < textRange.End)
{
var currentBreakPosition = 0;
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{
var nextBreakPosition = lineBreaker.Current.PositionWrap;
if (nextBreakPosition == 0)
{
break;
}
if (nextBreakPosition > measuredLength)
{
break;
}
currentBreakPosition = nextBreakPosition;
}
measuredLength = currentBreakPosition;
}
}
var splitResult = SplitTextRuns(textRuns, measuredLength);
var trimmedRuns = new List<ShapedTextCharacters>(splitResult.First.Count + 1);
trimmedRuns.AddRange(splitResult.First);
trimmedRuns.Add(ellipsisRun);
var textLineMetrics =
TextLineMetrics.Create(trimmedRuns, textRange, paragraphWidth, paragraphProperties);
return new TextLineImpl(trimmedRuns, textLineMetrics);
}
availableWidth -= currentRun.GlyphRun.Bounds.Width;
runIndex++;
}
return new TextLineImpl(textRuns,
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties));
}
/// <summary>
/// Performs text wrapping returns a list of text lines.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="textRange">The text range that is covered by the text runs.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="paragraphProperties">The text paragraph properties.</param>
/// <returns>The wrapped text line.</returns>
private static TextLine PerformTextWrapping(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange,
double paragraphWidth, TextParagraphProperties paragraphProperties)
{
var availableWidth = paragraphWidth;
var currentWidth = 0.0;
var runIndex = 0;
var length = 0;
while (runIndex < textRuns.Count)
{
var currentRun = textRuns[runIndex];
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth)
{
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth);
if (measuredLength < currentRun.Text.Length)
{
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow)
{
var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(measuredLength));
if (lineBreaker.MoveNext())
{
measuredLength += lineBreaker.Current.PositionWrap;
}
else
{
measuredLength = currentRun.Text.Length;
}
}
else
{
var currentBreakPosition = -1;
var lineBreaker = new LineBreakEnumerator(currentRun.Text);
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext())
{
var nextBreakPosition = lineBreaker.Current.PositionWrap;
if (nextBreakPosition == 0)
{
break;
}
if (nextBreakPosition > measuredLength)
{
break;
}
currentBreakPosition = nextBreakPosition;
}
if (currentBreakPosition != -1)
{
measuredLength = currentBreakPosition;
}
}
}
length += measuredLength;
var splitResult = SplitTextRuns(textRuns, length);
var textLineMetrics = TextLineMetrics.Create(splitResult.First,
new TextRange(textRange.Start, length), paragraphWidth, paragraphProperties);
var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ?
new TextLineBreak(splitResult.Second) :
null;
return new TextLineImpl(splitResult.First, textLineMetrics, lineBreak);
}
currentWidth += currentRun.GlyphRun.Bounds.Width;
length += currentRun.GlyphRun.Characters.Length;
runIndex++;
}
return new TextLineImpl(textRuns,
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties));
}
/// <summary>
/// Measures the number of characters that fits into available width.
/// </summary>
/// <param name="textCharacters">The text run.</param>
/// <param name="availableWidth">The available width.</param>
/// <returns></returns>
private static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth)
{
var glyphRun = textCharacters.GlyphRun;
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _);
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textCharacters.Text.Start;
}
/// <summary>
/// Creates an ellipsis.
/// </summary>
/// <param name="properties">The text run properties.</param>
/// <returns></returns>
private static ShapedTextCharacters CreateEllipsisRun(TextRunProperties properties)
{
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>();
var glyphRun = formatterImpl.ShapeText(s_ellipsis, properties.Typeface, properties.FontRenderingEmSize,
properties.CultureInfo);
return new ShapedTextCharacters(glyphRun, properties);
}
/// <summary>
/// Gets the text range that is covered by the text runs.
/// </summary>
/// <param name="textRuns">The text runs.</param>
/// <returns>The text range that is covered by the text runs.</returns>
private static TextRange GetTextRange(IReadOnlyList<TextRun> textRuns)
{
if (textRuns is null || textRuns.Count == 0)
{
return new TextRange();
}
var firstTextRun = textRuns[0];
if (textRuns.Count == 1)
{
return new TextRange(firstTextRun.Text.Start, firstTextRun.Text.Length);
}
var start = firstTextRun.Text.Start;
var end = textRuns[textRuns.Count - 1].Text.End + 1;
return new TextRange(start, end - start);
}
/// <summary>
/// Split a sequence of runs into two segments at specified length.
/// </summary>
/// <param name="textRuns">The text run's.</param>
/// <param name="length">The length to split at.</param>
/// <returns>The split text runs.</returns>
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextCharacters> textRuns, int length)
{
var currentLength = 0;
for (var i = 0; i < textRuns.Count; i++)
{
var currentRun = textRuns[i];
if (currentLength + currentRun.GlyphRun.Characters.Length < length)
{
currentLength += currentRun.GlyphRun.Characters.Length;
continue;
}
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i;
var first = new ShapedTextCharacters[firstCount];
if (firstCount > 1)
{
for (var j = 0; j < i; j++)
{
first[j] = textRuns[j];
}
}
var secondCount = textRuns.Count - firstCount;
if (currentLength + currentRun.GlyphRun.Characters.Length == length)
{
var second = new ShapedTextCharacters[secondCount];
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0;
if (secondCount > 0)
{
for (var j = 0; j < secondCount; j++)
{
second[j] = textRuns[i + j + offset];
}
}
first[i] = currentRun;
return new SplitTextRunsResult(first, second);
}
else
{
secondCount++;
var second = new ShapedTextCharacters[secondCount];
if (secondCount > 0)
{
for (var j = 1; j < secondCount; j++)
{
second[j] = textRuns[i + j];
}
}
var split = currentRun.Split(length - currentLength);
first[i] = split.First;
second[0] = split.Second;
return new SplitTextRunsResult(first, second);
}
}
return new SplitTextRunsResult(textRuns, null);
}
private readonly struct SplitTextRunsResult
{
public SplitTextRunsResult(IReadOnlyList<ShapedTextCharacters> first, IReadOnlyList<ShapedTextCharacters> second)
{
First = first;
Second = second;
}
/// <summary>
/// Gets the first text runs.
/// </summary>
/// <value>
/// The first text runs.
/// </value>
public IReadOnlyList<ShapedTextCharacters> First { get; }
/// <summary>
/// Gets the second text runs.
/// </summary>
/// <value>
/// The second text runs.
/// </value>
public IReadOnlyList<ShapedTextCharacters> Second { get; }
}
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.TextSourceLength == 0)
{
return false;
}
_pos += Current.TextSourceLength;
return !(Current is TextEndOfLine);
}
}
}
}

9
src/Avalonia.Visuals/Media/TextFormatting/TextHidden.cs

@ -0,0 +1,9 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Specialized text run used to mark a range of hidden characters
/// </summary>
public class TextHidden : TextRun
{
}
}

192
src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs

@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.Immutable;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
using Avalonia.Utility;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting
{
@ -14,11 +12,11 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public class TextLayout
{
private static readonly ReadOnlySlice<char> s_empty = new ReadOnlySlice<char>(new[] { '\u200B' });
private static readonly char[] s_empty = { '\u200B' };
private readonly ReadOnlySlice<char> _text;
private readonly TextParagraphProperties _paragraphProperties;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textStyleOverrides;
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
@ -33,6 +31,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textDecorations">The text decorations.</param>
/// <param name="maxWidth">The maximum width.</param>
/// <param name="maxHeight">The maximum height.</param>
/// <param name="lineHeight">The height of each line of text.</param>
/// <param name="maxLines">The maximum number of text lines.</param>
/// <param name="textStyleOverrides">The text style overrides.</param>
public TextLayout(
@ -46,18 +45,22 @@ namespace Avalonia.Media.TextFormatting
TextDecorationCollection textDecorations = null,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
double lineHeight = double.NaN,
int maxLines = 0,
IReadOnlyList<TextStyleRun> textStyleOverrides = null)
IReadOnlyList<ValueSpan<TextRunProperties>> textStyleOverrides = null)
{
_text = string.IsNullOrEmpty(text) ?
new ReadOnlySlice<char>() :
new ReadOnlySlice<char>(text.AsMemory());
_paragraphProperties =
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, textDecorations?.ToImmutable());
CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming,
textDecorations, lineHeight);
_textStyleOverrides = textStyleOverrides;
LineHeight = lineHeight;
MaxWidth = maxWidth;
MaxHeight = maxHeight;
@ -67,22 +70,29 @@ namespace Avalonia.Media.TextFormatting
UpdateLayout();
}
/// <summary>
/// Gets or sets the height of each line of text.
/// </summary>
/// <remarks>
/// A value of NaN (equivalent to an attribute value of "Auto") indicates that the line height
/// is determined automatically from the current font characteristics. The default is NaN.
/// </remarks>
public double LineHeight { get; }
/// <summary>
/// Gets the maximum width.
/// </summary>
public double MaxWidth { get; }
/// <summary>
/// Gets the maximum height.
/// </summary>
public double MaxHeight { get; }
/// <summary>
/// Gets the maximum number of text lines.
/// </summary>
public double MaxLines { get; }
public int MaxLines { get; }
/// <summary>
/// Gets the text lines.
@ -105,7 +115,7 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
/// <param name="context">The drawing context.</param>
/// <param name="origin">The origin.</param>
public void Draw(IDrawingContextImpl context, Point origin)
public void Draw(DrawingContext context, Point origin)
{
if (!TextLines.Any())
{
@ -132,14 +142,16 @@ namespace Avalonia.Media.TextFormatting
/// <param name="textWrapping">The text wrapping.</param>
/// <param name="textTrimming">The text trimming.</param>
/// <param name="textDecorations">The text decorations.</param>
/// <param name="lineHeight">The height of each line of text.</param>
/// <returns></returns>
private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize,
IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextTrimming textTrimming,
ImmutableTextDecoration[] textDecorations)
TextDecorationCollection textDecorations, double lineHeight)
{
var textRunStyle = new TextStyle(typeface, fontSize, foreground, textDecorations);
var textRunStyle = new GenericTextRunProperties(typeface, fontSize, textDecorations, foreground);
return new TextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming);
return new GenericTextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming,
lineHeight);
}
/// <summary>
@ -170,14 +182,15 @@ namespace Avalonia.Media.TextFormatting
/// <returns>The empty text line.</returns>
private TextLine CreateEmptyTextLine(int startingIndex)
{
var textFormat = _paragraphProperties.DefaultTextStyle.TextFormat;
var properties = _paragraphProperties.DefaultTextRunProperties;
var glyphRun = TextShaper.Current.ShapeText(s_empty, textFormat);
var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice<char>(s_empty, startingIndex, 1),
properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo);
var textRuns = new[] { new ShapedTextRun(glyphRun, _paragraphProperties.DefaultTextStyle) };
var textRuns = new[] { new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) };
return new SimpleTextLine(new TextPointer(startingIndex, 0), textRuns,
TextLineMetrics.Create(textRuns, MaxWidth, _paragraphProperties.TextAlignment));
return new TextLineImpl(textRuns,
TextLineMetrics.Create(textRuns, new TextRange(startingIndex, 1), MaxWidth, _paragraphProperties));
}
/// <summary>
@ -199,77 +212,38 @@ namespace Avalonia.Media.TextFormatting
double left = 0.0, right = 0.0, bottom = 0.0;
var lineBreaker = new LineBreakEnumerator(_text);
var currentPosition = 0;
var textSource = new FormattedTextSource(_text,
_paragraphProperties.DefaultTextRunProperties, _textStyleOverrides);
TextLineBreak previousLineBreak = null;
while (currentPosition < _text.Length && (MaxLines == 0 || textLines.Count < MaxLines))
{
int length;
var textLine = TextFormatter.Current.FormatLine(textSource, currentPosition, MaxWidth,
_paragraphProperties, previousLineBreak);
if (lineBreaker.MoveNext())
{
if (!lineBreaker.Current.Required)
{
continue;
}
previousLineBreak = textLine.LineBreak;
length = lineBreaker.Current.PositionWrap - currentPosition;
textLines.Add(textLine);
if (currentPosition + length < _text.Length)
{
//The line breaker isn't treating \n\r as a pair so we have to fix that here.
if (_text[lineBreaker.Current.PositionMeasure] == '\n'
&& _text[lineBreaker.Current.PositionWrap] == '\r')
{
length++;
}
}
}
else
UpdateBounds(textLine, ref left, ref right, ref bottom);
if (!double.IsPositiveInfinity(MaxHeight) && bottom > MaxHeight)
{
length = _text.Length - currentPosition;
break;
}
var remainingLength = length;
currentPosition += textLine.TextRange.Length;
while (remainingLength > 0 && (MaxLines == 0 || textLines.Count < MaxLines))
if (currentPosition != _text.Length || textLine.LineBreak == null)
{
var textSlice = _text.AsSlice(currentPosition, remainingLength);
var textSource = new FormattedTextSource(textSlice, _paragraphProperties.DefaultTextStyle, _textStyleOverrides);
var textLine = TextFormatter.Current.FormatLine(textSource, 0, MaxWidth, _paragraphProperties);
UpdateBounds(textLine, ref left, ref right, ref bottom);
textLines.Add(textLine);
if (!double.IsPositiveInfinity(MaxHeight) && bottom + textLine.LineMetrics.Size.Height > MaxHeight)
{
currentPosition = _text.Length;
break;
}
if (_paragraphProperties.TextTrimming != TextTrimming.None)
{
currentPosition += remainingLength;
break;
}
remainingLength -= textLine.Text.Length;
currentPosition += textLine.Text.Length;
continue;
}
}
if (lineBreaker.Current.Required && currentPosition == _text.Length)
{
var emptyTextLine = CreateEmptyTextLine(currentPosition);
UpdateBounds(emptyTextLine, ref left, ref right, ref bottom);
textLines.Add(emptyTextLine);
}
@ -279,22 +253,27 @@ namespace Avalonia.Media.TextFormatting
}
}
private struct FormattedTextSource : ITextSource
private readonly struct FormattedTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultStyle;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides;
private readonly TextRunProperties _defaultProperties;
private readonly IReadOnlyList<ValueSpan<TextRunProperties>> _textModifier;
public FormattedTextSource(ReadOnlySlice<char> text, TextStyle defaultStyle,
IReadOnlyList<TextStyleRun> textStyleOverrides)
public FormattedTextSource(ReadOnlySlice<char> text, TextRunProperties defaultProperties,
IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
{
_text = text;
_defaultStyle = defaultStyle;
_textStyleOverrides = textStyleOverrides;
_defaultProperties = defaultProperties;
_textModifier = textModifier;
}
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex > _text.End)
{
return new TextEndOfLine();
}
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
@ -302,30 +281,29 @@ namespace Avalonia.Media.TextFormatting
return new TextEndOfLine();
}
var textStyleRun = CreateTextStyleRunWithOverride(runText, _defaultStyle, _textStyleOverrides);
var textStyleRun = CreateTextStyleRun(runText, _defaultProperties, _textModifier);
return new TextCharacters(runText.Take(textStyleRun.TextPointer.Length), textStyleRun.Style);
return new TextCharacters(runText.Take(textStyleRun.Length), textStyleRun.Value);
}
/// <summary>
/// Creates a text style run that has overrides applied. Only overrides with equal TextStyle.
/// If optimizeForShaping is <c>true</c> Foreground is ignored.
/// Creates a span of text run properties that has modifier applied.
/// </summary>
/// <param name="text">The text to create the run for.</param>
/// <param name="defaultTextStyle">The default text style for segments that don't have an override.</param>
/// <param name="textStyleOverrides">The text style overrides.</param>
/// <param name="text">The text to create the properties for.</param>
/// <param name="defaultProperties">The default text properties.</param>
/// <param name="textModifier">The text properties modifier.</param>
/// <returns>
/// The created text style run.
/// </returns>
private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice<char> text,
TextStyle defaultTextStyle, IReadOnlyList<TextStyleRun> textStyleOverrides)
private static ValueSpan<TextRunProperties> CreateTextStyleRun(ReadOnlySlice<char> text,
TextRunProperties defaultProperties, IReadOnlyList<ValueSpan<TextRunProperties>> textModifier)
{
if(textStyleOverrides == null || textStyleOverrides.Count == 0)
if (textModifier == null || textModifier.Count == 0)
{
return new TextStyleRun(new TextPointer(text.Start, text.Length), defaultTextStyle);
return new ValueSpan<TextRunProperties>(text.Start, text.Length, defaultProperties);
}
var currentTextStyle = defaultTextStyle;
var currentProperties = defaultProperties;
var hasOverride = false;
@ -333,35 +311,34 @@ namespace Avalonia.Media.TextFormatting
var length = 0;
for (; i < textStyleOverrides.Count; i++)
for (; i < textModifier.Count; i++)
{
var styleOverride = textStyleOverrides[i];
var propertiesOverride = textModifier[i];
var textPointer = styleOverride.TextPointer;
var textRange = new TextRange(propertiesOverride.Start, propertiesOverride.Length);
if (textPointer.End < text.Start)
if (textRange.End < text.Start)
{
continue;
}
if (textPointer.Start > text.End)
if (textRange.Start > text.End)
{
length = text.Length;
break;
}
if (textPointer.Start > text.Start)
if (textRange.Start > text.Start)
{
if (styleOverride.Style.TextFormat != currentTextStyle.TextFormat ||
!currentTextStyle.Foreground.Equals(styleOverride.Style.Foreground))
if (propertiesOverride.Value != currentProperties)
{
length = Math.Min(Math.Abs(textPointer.Start - text.Start), text.Length);
length = Math.Min(Math.Abs(textRange.Start - text.Start), text.Length);
break;
}
}
length += Math.Min(text.Length - length, textPointer.Length);
length += Math.Min(text.Length - length, textRange.Length);
if (hasOverride)
{
@ -370,13 +347,12 @@ namespace Avalonia.Media.TextFormatting
hasOverride = true;
currentTextStyle = styleOverride.Style;
currentProperties = propertiesOverride.Value;
}
if (length < text.Length && i == textStyleOverrides.Count)
if (length < text.Length && i == textModifier.Count)
{
if (currentTextStyle.Foreground.Equals(defaultTextStyle.Foreground) &&
currentTextStyle.TextFormat == defaultTextStyle.TextFormat)
if (currentProperties == defaultProperties)
{
length = text.Length;
}
@ -387,7 +363,7 @@ namespace Avalonia.Media.TextFormatting
text = text.Take(length);
}
return new TextStyleRun(new TextPointer(text.Start, length), currentTextStyle);
return new ValueSpan<TextRunProperties>(text.Start, length, currentProperties);
}
}
}

17
src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs

@ -1,5 +1,4 @@
using System.Collections.Generic;
using Avalonia.Platform;
namespace Avalonia.Media.TextFormatting
{
@ -9,12 +8,12 @@ namespace Avalonia.Media.TextFormatting
public abstract class TextLine
{
/// <summary>
/// Gets the text.
/// Gets the text range that is covered by the line.
/// </summary>
/// <value>
/// The text pointer.
/// The text range that is covered by the line.
/// </value>
public abstract TextPointer Text { get; }
public abstract TextRange TextRange { get; }
/// <summary>
/// Gets the text runs.
@ -32,12 +31,20 @@ namespace Avalonia.Media.TextFormatting
/// </value>
public abstract TextLineMetrics LineMetrics { get; }
/// <summary>
/// Gets the state of the line when broken by line breaking process.
/// </summary>
/// <returns>
/// A <see cref="LineBreak"/> value that represents the line break.
/// </returns>
public abstract TextLineBreak LineBreak { get; }
/// <summary>
/// Draws the <see cref="TextLine"/> at the given origin.
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="origin">The origin.</param>
public abstract void Draw(IDrawingContextImpl drawingContext, Point origin);
public abstract void Draw(DrawingContext drawingContext, Point origin);
/// <summary>
/// Client to get the character hit corresponding to the specified

17
src/Avalonia.Visuals/Media/TextFormatting/TextLineBreak.cs

@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace Avalonia.Media.TextFormatting
{
public class TextLineBreak
{
public TextLineBreak(IReadOnlyList<ShapedTextCharacters> remainingCharacters)
{
RemainingCharacters = remainingCharacters;
}
/// <summary>
/// Get the remaining shaped characters that were split up by the <see cref="TextFormatter"/> during the formatting process.
/// </summary>
public IReadOnlyList<ShapedTextCharacters> RemainingCharacters { get; }
}
}

235
src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs

@ -0,0 +1,235 @@
using System.Collections.Generic;
namespace Avalonia.Media.TextFormatting
{
internal class TextLineImpl : TextLine
{
private readonly IReadOnlyList<ShapedTextCharacters> _textRuns;
public TextLineImpl(IReadOnlyList<ShapedTextCharacters> textRuns, TextLineMetrics lineMetrics,
TextLineBreak lineBreak = null)
{
_textRuns = textRuns;
LineMetrics = lineMetrics;
LineBreak = lineBreak;
}
/// <inheritdoc/>
public override TextRange TextRange => LineMetrics.TextRange;
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextLineMetrics LineMetrics { get; }
/// <inheritdoc/>
public override TextLineBreak LineBreak { get; }
/// <inheritdoc/>
public override void Draw(DrawingContext drawingContext, Point origin)
{
var currentX = origin.X;
foreach (var textRun in _textRuns)
{
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X,
origin.Y + LineMetrics.BaselineOrigin.Y);
textRun.Draw(drawingContext, baselineOrigin);
currentX += textRun.Bounds.Width;
}
}
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
if (distance < 0)
{
// hit happens before the line, return the first position
return new CharacterHit(TextRange.Start);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
foreach (var run in _textRuns)
{
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
if (distance <= run.Bounds.Width)
{
break;
}
distance -= run.Bounds.Width;
}
return characterHit;
}
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
}
/// <inheritdoc/>
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
{
if (TryFindNextCharacterHit(characterHit, out var nextCharacterHit))
{
return nextCharacterHit;
}
return new CharacterHit(TextRange.End); // Can't move, we're after the last character
}
/// <inheritdoc/>
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
{
if (TryFindPreviousCharacterHit(characterHit, out var previousCharacterHit))
{
return previousCharacterHit;
}
return new CharacterHit(TextRange.Start); // Can't move, we're before the first character
}
/// <inheritdoc/>
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit)
{
// same operation as move-to-previous
return GetPreviousCaretCharacterHit(characterHit);
}
/// <summary>
/// Get distance from line start to the specified codepoint index.
/// </summary>
private double DistanceFromCodepointIndex(int codepointIndex)
{
var currentDistance = 0.0;
foreach (var textRun in _textRuns)
{
if (codepointIndex > textRun.Text.End)
{
currentDistance += textRun.Bounds.Width;
continue;
}
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex));
}
return currentDistance;
}
/// <summary>
/// Tries to find the next character hit.
/// </summary>
/// <param name="characterHit">The current character hit.</param>
/// <param name="nextCharacterHit">The next character hit.</param>
/// <returns></returns>
private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit nextCharacterHit)
{
nextCharacterHit = characterHit;
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex >= TextRange.Start + TextRange.Length)
{
return false; // Cannot go forward anymore
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
while (runIndex < TextRuns.Count)
{
var run = _textRuns[runIndex];
nextCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _);
if (codepointIndex <= nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength)
{
return true;
}
runIndex++;
}
return false;
}
/// <summary>
/// Tries to find the previous character hit.
/// </summary>
/// <param name="characterHit">The current character hit.</param>
/// <param name="previousCharacterHit">The previous character hit.</param>
/// <returns></returns>
private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out CharacterHit previousCharacterHit)
{
previousCharacterHit = characterHit;
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if (codepointIndex < TextRange.Start)
{
return false; // Cannot go backward anymore.
}
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex);
while (runIndex >= 0)
{
var run = _textRuns[runIndex];
previousCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _);
if (previousCharacterHit.FirstCharacterIndex < codepointIndex)
{
return true;
}
runIndex--;
}
return false;
}
/// <summary>
/// Gets the run index of the specified codepoint index.
/// </summary>
/// <param name="codepointIndex">The codepoint index.</param>
/// <returns>The text run index.</returns>
private int GetRunIndexAtCodepointIndex(int codepointIndex)
{
if (codepointIndex >= TextRange.End)
{
return _textRuns.Count - 1;
}
if (codepointIndex <= 0)
{
return 0;
}
var runIndex = 0;
while (runIndex < _textRuns.Count)
{
var run = _textRuns[runIndex];
if (run.Text.End > codepointIndex)
{
return runIndex;
}
runIndex++;
}
return runIndex;
}
}
}

69
src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs

@ -1,4 +1,5 @@
using System.Collections.Generic;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -8,38 +9,20 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public readonly struct TextLineMetrics
{
public TextLineMetrics(double width, double xOrigin, double ascent, double descent, double lineGap)
public TextLineMetrics(Size size, Point baselineOrigin, TextRange textRange)
{
Ascent = ascent;
Descent = descent;
LineGap = lineGap;
Size = new Size(width, descent - ascent + lineGap);
BaselineOrigin = new Point(xOrigin, -ascent);
Size = size;
BaselineOrigin = baselineOrigin;
TextRange = textRange;
}
/// <summary>
/// Gets the overall recommended distance above the baseline.
/// Gets the text range that is covered by the text line.
/// </summary>
/// <value>
/// The ascent.
/// The text range that is covered by the text line.
/// </value>
public double Ascent { get; }
/// <summary>
/// Gets the overall recommended distance under the baseline.
/// </summary>
/// <value>
/// The descent.
/// </value>
public double Descent { get; }
/// <summary>
/// Gets the overall recommended additional space between two lines of text.
/// </summary>
/// <value>
/// The leading.
/// </value>
public double LineGap { get; }
public TextRange TextRange { get; }
/// <summary>
/// Gets the size of the text line.
@ -61,10 +44,12 @@ namespace Avalonia.Media.TextFormatting
/// Creates the text line metrics.
/// </summary>
/// <param name="textRuns">The text runs.</param>
/// <param name="textRange">The text range that is covered by the text line.</param>
/// <param name="paragraphWidth">The paragraph width.</param>
/// <param name="textAlignment">The text alignment.</param>
/// <param name="paragraphProperties">The text alignment.</param>
/// <returns></returns>
public static TextLineMetrics Create(IEnumerable<TextRun> textRuns, double paragraphWidth, TextAlignment textAlignment)
public static TextLineMetrics Create(IEnumerable<TextRun> textRuns, TextRange textRange, double paragraphWidth,
TextParagraphProperties paragraphProperties)
{
var lineWidth = 0.0;
var ascent = 0.0;
@ -73,31 +58,39 @@ namespace Avalonia.Media.TextFormatting
foreach (var textRun in textRuns)
{
var shapedRun = (ShapedTextRun)textRun;
var shapedRun = (ShapedTextCharacters)textRun;
lineWidth += shapedRun.Bounds.Width;
var fontMetrics =
new FontMetrics(shapedRun.Properties.Typeface, shapedRun.Properties.FontRenderingEmSize);
var textFormat = textRun.Style.TextFormat;
lineWidth += shapedRun.Bounds.Width;
if (ascent > textRun.Style.TextFormat.FontMetrics.Ascent)
if (ascent > fontMetrics.Ascent)
{
ascent = textFormat.FontMetrics.Ascent;
ascent = fontMetrics.Ascent;
}
if (descent < textFormat.FontMetrics.Descent)
if (descent < fontMetrics.Descent)
{
descent = textFormat.FontMetrics.Descent;
descent = fontMetrics.Descent;
}
if (lineGap < textFormat.FontMetrics.LineGap)
if (lineGap < fontMetrics.LineGap)
{
lineGap = textFormat.FontMetrics.LineGap;
lineGap = fontMetrics.LineGap;
}
}
var xOrigin = TextLine.GetParagraphOffsetX(lineWidth, paragraphWidth, textAlignment);
var xOrigin = TextLine.GetParagraphOffsetX(lineWidth, paragraphWidth, paragraphProperties.TextAlignment);
var baselineOrigin = new Point(xOrigin, -ascent);
var size = new Size(lineWidth,
double.IsNaN(paragraphProperties.LineHeight) || MathUtilities.IsZero(paragraphProperties.LineHeight) ?
descent - ascent + lineGap :
paragraphProperties.LineHeight);
return new TextLineMetrics(lineWidth, xOrigin, ascent, descent, lineGap);
return new TextLineMetrics(size, baselineOrigin, textRange);
}
}
}

19
src/Avalonia.Visuals/Media/TextFormatting/TextModifier.cs

@ -0,0 +1,19 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Specialized text run used to modify properties of text runs in its scope.
/// The scope extends to the next matching EndOfSegment text run (matching
/// because text modifiers may be nested), or to the next EndOfParagraph.
/// </summary>
public abstract class TextModifier : TextRun
{
/// <summary>
/// Modifies the properties of a text run.
/// </summary>
/// <param name="properties">Properties of a text run or the return value of
/// ModifyProperties for a nested text modifier.</param>
/// <returns>Returns the actual text run properties to be used for formatting,
/// subject to further modification by text modifiers at outer scopes.</returns>
public abstract TextRunProperties ModifyProperties(TextRunProperties properties);
}
}

33
src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs

@ -3,38 +3,37 @@
/// <summary>
/// Provides a set of properties that are used during the paragraph layout.
/// </summary>
public readonly struct TextParagraphProperties
public abstract class TextParagraphProperties
{
public TextParagraphProperties(
TextStyle defaultTextStyle,
TextAlignment textAlignment = TextAlignment.Left,
TextWrapping textWrapping = TextWrapping.NoWrap,
TextTrimming textTrimming = TextTrimming.None)
{
DefaultTextStyle = defaultTextStyle;
TextAlignment = textAlignment;
TextWrapping = textWrapping;
TextTrimming = textTrimming;
}
/// <summary>
/// Gets the text alignment.
/// </summary>
public abstract TextAlignment TextAlignment { get; }
/// <summary>
/// Gets the default text style.
/// </summary>
public TextStyle DefaultTextStyle { get; }
public abstract TextRunProperties DefaultTextRunProperties { get; }
/// <summary>
/// Gets the text alignment.
/// If not null, text decorations to apply to all runs in the line. This is in addition
/// to any text decorations specified by the TextRunProperties for individual text runs.
/// </summary>
public TextAlignment TextAlignment { get; }
public virtual TextDecorationCollection TextDecorations => null;
/// <summary>
/// Gets the text wrapping.
/// </summary>
public TextWrapping TextWrapping { get; }
public abstract TextWrapping TextWrapping { get; }
/// <summary>
/// Gets the text trimming.
/// </summary>
public TextTrimming TextTrimming { get; }
public abstract TextTrimming TextTrimming { get; }
/// <summary>
/// Paragraph's line height
/// </summary>
public abstract double LineHeight { get; }
}
}

16
src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs → src/Avalonia.Visuals/Media/TextFormatting/TextRange.cs

@ -5,9 +5,9 @@ namespace Avalonia.Media.TextFormatting
/// <summary>
/// References a portion of a text buffer.
/// </summary>
public readonly struct TextPointer
public readonly struct TextRange
{
public TextPointer(int start, int length)
public TextRange(int start, int length)
{
Start = start;
Length = length;
@ -41,30 +41,30 @@ namespace Avalonia.Media.TextFormatting
/// Returns a specified number of contiguous elements from the start of the slice.
/// </summary>
/// <param name="length">The number of elements to return.</param>
/// <returns>A <see cref="TextPointer"/> that contains the specified number of elements from the start of this slice.</returns>
public TextPointer Take(int length)
/// <returns>A <see cref="TextRange"/> that contains the specified number of elements from the start of this slice.</returns>
public TextRange Take(int length)
{
if (length > Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
return new TextPointer(Start, length);
return new TextRange(Start, length);
}
/// <summary>
/// Bypasses a specified number of elements in the slice and then returns the remaining elements.
/// </summary>
/// <param name="length">The number of elements to skip before returning the remaining elements.</param>
/// <returns>A <see cref="TextPointer"/> that contains the elements that occur after the specified index in this slice.</returns>
public TextPointer Skip(int length)
/// <returns>A <see cref="TextRange"/> that contains the elements that occur after the specified index in this slice.</returns>
public TextRange Skip(int length)
{
if (length > Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
return new TextPointer(Start + length, Length - length);
return new TextRange(Start + length, Length - length);
}
}
}

17
src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs

@ -1,5 +1,5 @@
using System.Diagnostics;
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -9,15 +9,22 @@ namespace Avalonia.Media.TextFormatting
[DebuggerTypeProxy(typeof(TextRunDebuggerProxy))]
public abstract class TextRun
{
public static readonly int DefaultTextSourceLength = 1;
/// <summary>
/// Gets the text source length.
/// </summary>
public virtual int TextSourceLength => DefaultTextSourceLength;
/// <summary>
/// Gets the text run's text.
/// </summary>
public ReadOnlySlice<char> Text { get; protected set; }
public virtual ReadOnlySlice<char> Text => default;
/// <summary>
/// Gets the text run's style.
/// A set of properties shared by every characters in the run
/// </summary>
public TextStyle Style { get; protected set; }
public virtual TextRunProperties Properties => null;
private class TextRunDebuggerProxy
{
@ -42,7 +49,7 @@ namespace Avalonia.Media.TextFormatting
}
}
public TextStyle Style => _textRun.Style;
public TextRunProperties Properties => _textRun.Properties;
}
}
}

90
src/Avalonia.Visuals/Media/TextFormatting/TextRunProperties.cs

@ -0,0 +1,90 @@
using System;
using System.Globalization;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Properties that can change from one run to the next, such as typeface or foreground brush.
/// </summary>
/// <remarks>
/// The client provides a concrete implementation of this abstract run properties class. This
/// allows client to implement their run properties the way that fits with their run formatting
/// store.
/// </remarks>
public abstract class TextRunProperties : IEquatable<TextRunProperties>
{
/// <summary>
/// Run typeface
/// </summary>
public abstract Typeface Typeface { get; }
/// <summary>
/// Em size of font used to format and display text
/// </summary>
public abstract double FontRenderingEmSize { get; }
///<summary>
/// Run TextDecorations.
///</summary>
public abstract TextDecorationCollection TextDecorations { get; }
/// <summary>
/// Brush used to fill text.
/// </summary>
public abstract IBrush ForegroundBrush { get; }
/// <summary>
/// Brush used to paint background of run.
/// </summary>
public abstract IBrush BackgroundBrush { get; }
/// <summary>
/// Run text culture.
/// </summary>
public abstract CultureInfo CultureInfo { get; }
public bool Equals(TextRunProperties other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Typeface.Equals(other.Typeface) &&
FontRenderingEmSize.Equals(other.FontRenderingEmSize)
&& Equals(TextDecorations, other.TextDecorations) &&
Equals(ForegroundBrush, other.ForegroundBrush) &&
Equals(BackgroundBrush, other.BackgroundBrush) &&
Equals(CultureInfo, other.CultureInfo);
}
public override bool Equals(object obj)
{
return ReferenceEquals(this, obj) || obj is TextRunProperties other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode();
hashCode = (hashCode * 397) ^ (TextDecorations != null ? TextDecorations.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (ForegroundBrush != null ? ForegroundBrush.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (BackgroundBrush != null ? BackgroundBrush.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (CultureInfo != null ? CultureInfo.GetHashCode() : 0);
return hashCode;
}
}
public static bool operator ==(TextRunProperties left, TextRunProperties right)
{
return Equals(left, right);
}
public static bool operator !=(TextRunProperties left, TextRunProperties right)
{
return !Equals(left, right);
}
}
}

8
src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs

@ -1,6 +1,7 @@
using System;
using System.Globalization;
using Avalonia.Platform;
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
{
@ -44,9 +45,10 @@ namespace Avalonia.Media.TextFormatting
}
/// <inheritdoc cref="ITextShaperImpl.ShapeText"/>
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat)
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize,
CultureInfo culture)
{
return _platformImpl.ShapeText(text, textFormat);
return _platformImpl.ShapeText(text, typeface, fontRenderingEmSize, culture);
}
}
}

39
src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs

@ -1,39 +0,0 @@
using Avalonia.Media.Immutable;
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Unique text formatting properties that effect the styling of a text.
/// </summary>
public readonly struct TextStyle
{
public TextStyle(Typeface typeface, double fontRenderingEmSize = 12, IBrush foreground = null,
ImmutableTextDecoration[] textDecorations = null)
: this(new TextFormat(typeface, fontRenderingEmSize), foreground, textDecorations)
{
}
public TextStyle(TextFormat textFormat, IBrush foreground = null,
ImmutableTextDecoration[] textDecorations = null)
{
TextFormat = textFormat;
Foreground = foreground;
TextDecorations = textDecorations;
}
/// <summary>
/// Gets the text format.
/// </summary>
public TextFormat TextFormat { get; }
/// <summary>
/// Gets the foreground.
/// </summary>
public IBrush Foreground { get; }
/// <summary>
/// Gets the text decorations.
/// </summary>
public ImmutableTextDecoration[] TextDecorations { get; }
}
}

24
src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs

@ -1,24 +0,0 @@
namespace Avalonia.Media.TextFormatting
{
/// <summary>
/// Represents a text run's style and is used during the layout process of the <see cref="TextFormatter"/>.
/// </summary>
public readonly struct TextStyleRun
{
public TextStyleRun(TextPointer textPointer, TextStyle style)
{
TextPointer = textPointer;
Style = style;
}
/// <summary>
/// Gets the text pointer.
/// </summary>
public TextPointer TextPointer { get; }
/// <summary>
/// Gets the text style.
/// </summary>
public TextStyle Style { get; }
}
}

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs

@ -1,4 +1,4 @@
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs

@ -1,4 +1,4 @@
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs

@ -1,4 +1,4 @@
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs

@ -4,7 +4,7 @@
// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
using System.Runtime.InteropServices;
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{

2
src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs

@ -16,7 +16,7 @@
// Ported from: https://github.com/foliojs/linebreak
// Copied from: https://github.com/toptensoftware/RichTextKit
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting.Unicode
{

9
src/Avalonia.Visuals/Media/TextWrapping.cs

@ -5,6 +5,13 @@ namespace Avalonia.Media
/// </summary>
public enum TextWrapping
{
/// <summary>
/// Line-breaking occurs if the line overflows the available block width.
/// However, a line may overflow the block width if the line breaking algorithm
/// cannot determine a break opportunity, as in the case of a very long word.
/// </summary>
WrapWithOverflow,
/// <summary>
/// Text should not wrap.
/// </summary>
@ -15,4 +22,4 @@ namespace Avalonia.Media
/// </summary>
Wrap
}
}
}

12
src/Avalonia.Visuals/Media/Typeface.cs

@ -16,11 +16,11 @@ namespace Avalonia.Media
/// Initializes a new instance of the <see cref="Typeface"/> class.
/// </summary>
/// <param name="fontFamily">The font family.</param>
/// <param name="weight">The font weight.</param>
/// <param name="style">The font style.</param>
/// <param name="weight">The font weight.</param>
public Typeface([NotNull]FontFamily fontFamily,
FontWeight weight = FontWeight.Normal,
FontStyle style = FontStyle.Normal)
FontStyle style = FontStyle.Normal,
FontWeight weight = FontWeight.Normal)
{
if (weight <= 0)
{
@ -39,9 +39,9 @@ namespace Avalonia.Media
/// <param name="style">The font style.</param>
/// <param name="weight">The font weight.</param>
public Typeface(string fontFamilyName,
FontWeight weight = FontWeight.Normal,
FontStyle style = FontStyle.Normal)
: this(new FontFamily(fontFamilyName), weight, style)
FontStyle style = FontStyle.Normal,
FontWeight weight = FontWeight.Normal)
: this(new FontFamily(fontFamilyName), style, weight)
{
}

12
src/Avalonia.Visuals/Platform/ITextShaperImpl.cs

@ -1,6 +1,6 @@
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Utility;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Utilities;
namespace Avalonia.Platform
{
@ -13,8 +13,10 @@ namespace Avalonia.Platform
/// Shapes the specified region within the text and returns a resulting glyph run.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="textFormat">The text format.</param>
/// <param name="typeface">The typeface.</param>
/// <param name="fontRenderingEmSize">The font rendering em size.</param>
/// <param name="culture">The culture.</param>
/// <returns>A shaped glyph run.</returns>
GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat);
GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture);
}
}

5
src/Avalonia.Visuals/Utility/ReadOnlySlice.cs → src/Avalonia.Visuals/Utilities/ReadOnlySlice.cs

@ -2,9 +2,8 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using Avalonia.Utilities;
namespace Avalonia.Utility
namespace Avalonia.Utilities
{
/// <summary>
/// ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region.
@ -47,7 +46,7 @@ namespace Avalonia.Utility
public int Length { get; }
/// <summary>
/// Gets a value that indicates whether this instance of <see cref="ReadOnlySpan{T}"/> is Empty.
/// Gets a value that indicates whether this instance of <see cref="ReadOnlySlice{T}"/> is Empty.
/// </summary>
public bool IsEmpty => Length == 0;

30
src/Avalonia.Visuals/Utilities/ValueSpan.cs

@ -0,0 +1,30 @@
namespace Avalonia.Utilities
{
/// <summary>
/// Pairing of value and positions sharing that value.
/// </summary>
public readonly struct ValueSpan<T>
{
public ValueSpan(int start, int length, T value)
{
Start = start;
Length = length;
Value = value;
}
/// <summary>
/// Get's the start of the span.
/// </summary>
public int Start { get; }
/// <summary>
/// Get's the length of the span.
/// </summary>
public int Length { get; }
/// <summary>
/// Get's the value of the span.
/// </summary>
public T Value { get; }
}
}

2
src/Skia/Avalonia.Skia/FormattedTextImpl.cs

@ -569,7 +569,7 @@ namespace Avalonia.Skia
float constraint = -1;
if (_wrapping == TextWrapping.Wrap)
if (_wrapping != TextWrapping.NoWrap)
{
constraint = widthConstraint <= 0 ? MAX_LINE_WIDTH : widthConstraint;
if (constraint > MAX_LINE_WIDTH)

14
src/Skia/Avalonia.Skia/TextShaperImpl.cs

@ -1,9 +1,9 @@
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
using Avalonia.Utilities;
using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer;
@ -11,7 +11,7 @@ namespace Avalonia.Skia
{
internal class TextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat)
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{
using (var buffer = new Buffer())
{
@ -61,9 +61,11 @@ namespace Avalonia.Skia
buffer.AddUtf16(text.Buffer.Span);
}
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
buffer.GuessSegmentProperties();
var glyphTypeface = textFormat.Typeface.GlyphTypeface;
var glyphTypeface = typeface.GlyphTypeface;
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
@ -71,7 +73,7 @@ namespace Avalonia.Skia
font.GetScale(out var scaleX, out _);
var textScale = textFormat.FontRenderingEmSize / scaleX;
var textScale = fontRenderingEmSize / scaleX;
var bufferLength = buffer.Length;
@ -101,7 +103,7 @@ namespace Avalonia.Skia
SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
}
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize,
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets),

15
src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs

@ -1,8 +1,9 @@
using Avalonia.Media;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
using Avalonia.Utilities;
using HarfBuzzSharp;
using Buffer = HarfBuzzSharp.Buffer;
@ -10,7 +11,7 @@ namespace Avalonia.Direct2D1.Media
{
internal class TextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat)
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{
using (var buffer = new Buffer())
{
@ -62,15 +63,17 @@ namespace Avalonia.Direct2D1.Media
buffer.GuessSegmentProperties();
var glyphTypeface = textFormat.Typeface.GlyphTypeface;
var glyphTypeface = typeface.GlyphTypeface;
var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
font.Shape(buffer);
font.GetScale(out var scaleX, out _);
var textScale = textFormat.FontRenderingEmSize / scaleX;
var textScale = fontRenderingEmSize / scaleX;
var len = buffer.Length;
@ -104,7 +107,7 @@ namespace Avalonia.Direct2D1.Media
glyphOffsets[i] = new Vector(offsetX, offsetY);
}
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize,
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices),
new ReadOnlySlice<double>(glyphAdvances),
new ReadOnlySlice<Vector>(glyphOffsets),

4
tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs

@ -41,7 +41,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(new FontFamily("A, B, Arial"), FontWeight.Bold));
new Typeface(new FontFamily("A, B, Arial"), weight: FontWeight.Bold));
var font = glyphTypeface.DWFont;
@ -105,7 +105,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media
var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic));
new Typeface(s_fontUri, FontStyle.Italic, FontWeight.Black));
var font = glyphTypeface.DWFont;

2
tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs → tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs

@ -6,7 +6,7 @@ using Avalonia.Media.Fonts;
using Avalonia.Platform;
using SkiaSharp;
namespace Avalonia.Skia.UnitTests
namespace Avalonia.Skia.UnitTests.Media
{
public class CustomFontManagerImpl : IFontManagerImpl
{

8
tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs → tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs

@ -1,13 +1,11 @@
using System;
using System.Linq;
using System.Reflection;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.UnitTests;
using SkiaSharp;
using Xunit;
namespace Avalonia.Skia.UnitTests
namespace Avalonia.Skia.UnitTests.Media
{
public class FontManagerImplTests
{
@ -39,7 +37,7 @@ namespace Avalonia.Skia.UnitTests
string fontName = fontManager.GetInstalledFontFamilyNames().First();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(new FontFamily($"A, B, {fontName}"), FontWeight.Bold));
new Typeface(new FontFamily($"A, B, {fontName}"), weight: FontWeight.Bold));
var skTypeface = glyphTypeface.Typeface;
@ -88,7 +86,7 @@ namespace Avalonia.Skia.UnitTests
var fontManager = new FontManagerImpl();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic));
new Typeface(s_fontUri, FontStyle.Italic, FontWeight.Black));
var skTypeface = glyphTypeface.Typeface;

4
tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs → tests/Avalonia.Skia.UnitTests/Media/SKTypefaceCollectionCacheTests.cs

@ -2,7 +2,7 @@
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Skia.UnitTests
namespace Avalonia.Skia.UnitTests.Media
{
public class SKTypefaceCollectionCacheTests
{
@ -19,7 +19,7 @@ namespace Avalonia.Skia.UnitTests
var notoMonoCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(notoMono);
var typeface = new Typeface("ABC", FontWeight.Bold, FontStyle.Italic);
var typeface = new Typeface("ABC", FontStyle.Italic, FontWeight.Bold);
Assert.Equal("Noto Mono", notoMonoCollection.Get(typeface).FamilyName);

38
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/FormattableTextSource.cs

@ -0,0 +1,38 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class FormattableTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextRunProperties _defaultStyle;
private ReadOnlySlice<ValueSpan<TextRunProperties>> _styleSpans;
public FormattableTextSource(string text, TextRunProperties defaultStyle,
ReadOnlySlice<ValueSpan<TextRunProperties>> styleSpans)
{
_text = text.AsMemory();
_defaultStyle = defaultStyle;
_styleSpans = styleSpans;
}
public TextRun GetTextRun(int textSourceIndex)
{
if (_styleSpans.IsEmpty)
{
return new TextEndOfParagraph();
}
var currentSpan = _styleSpans[0];
_styleSpans = _styleSpans.Skip(1);
return new TextCharacters(_text.AsSlice(currentSpan.Start, currentSpan.Length),
_defaultStyle);
}
}
}

36
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/MultiBufferTextSource.cs

@ -0,0 +1,36 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class MultiBufferTextSource : ITextSource
{
private readonly string[] _runTexts;
private readonly GenericTextRunProperties _defaultStyle;
public MultiBufferTextSource(GenericTextRunProperties defaultStyle)
{
_defaultStyle = defaultStyle;
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" };
}
public static TextRange TextRange => new TextRange(0, 50);
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex == 50)
{
return new TextEndOfParagraph();
}
var index = textSourceIndex / 10;
var runText = _runTexts[index];
return new TextCharacters(
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle);
}
}
}

30
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/SingleBufferTextSource.cs

@ -0,0 +1,30 @@
using System;
using Avalonia.Media.TextFormatting;
using Avalonia.Utilities;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
internal class SingleBufferTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly GenericTextRunProperties _defaultGenericPropertiesRunProperties;
public SingleBufferTextSource(string text, GenericTextRunProperties defaultProperties)
{
_text = text.AsMemory();
_defaultGenericPropertiesRunProperties = defaultProperties;
}
public TextRun GetTextRun(int textSourceIndex)
{
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
return new TextCharacters(runText, _defaultGenericPropertiesRunProperties);
}
}
}

275
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextFormatterTests
{
[Fact]
public void Should_Format_TextRuns_With_Default_Style()
{
using (Start())
{
const string text = "0123456789";
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Single(textLine.TextRuns);
var textRun = textLine.TextRuns[0];
Assert.Equal(defaultProperties.Typeface, textRun.Properties.Typeface);
Assert.Equal(defaultProperties.ForegroundBrush, textRun.Properties.ForegroundBrush);
Assert.Equal(text.Length, textRun.Text.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_Multiple_Buffers()
{
using (Start())
{
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(5, textLine.TextRuns.Count);
Assert.Equal(50, textLine.TextRange.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_TextRunStyles()
{
using (Start())
{
const string text = "0123456789";
var defaultProperties =
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
var GenericTextRunPropertiesRuns = new[]
{
new ValueSpan<TextRunProperties>(0, 3, defaultProperties),
new ValueSpan<TextRunProperties>(3, 3,
new GenericTextRunProperties(Typeface.Default, 13, foregroundBrush: Brushes.Black)),
new ValueSpan<TextRunProperties>(6, 3,
new GenericTextRunProperties(Typeface.Default, 14, foregroundBrush: Brushes.Black)),
new ValueSpan<TextRunProperties>(9, 1, defaultProperties)
};
var textSource = new FormattableTextSource(text, defaultProperties, GenericTextRunPropertiesRuns);
var formatter = new TextFormatterImpl();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(text.Length, textLine.TextRange.Length);
for (var i = 0; i < GenericTextRunPropertiesRuns.Length; i++)
{
var GenericTextRunPropertiesRun = GenericTextRunPropertiesRuns[i];
var textRun = textLine.TextRuns[i];
Assert.Equal(GenericTextRunPropertiesRun.Length, textRun.Text.Length);
}
}
}
[Theory]
[InlineData("0123", 1)]
[InlineData("\r\n", 1)]
[InlineData("👍b", 2)]
[InlineData("a👍b", 3)]
[InlineData("a👍子b", 4)]
public void Should_Produce_Unique_Runs(string text, int numberOfRuns)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(numberOfRuns, textLine.TextRuns.Count);
}
}
[Fact]
public void Should_Split_Run_On_Script()
{
using (Start())
{
const string text = "1234الدولي";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
Assert.Equal(4, textLine.TextRuns[0].Text.Length);
}
}
[InlineData("𐐷𐐷𐐷𐐷𐐷", 10, 1)]
[InlineData("01234 56789 01234 56789", 6, 4)]
[Theory]
public void Should_Wrap_With_Overflow(string text, int expectedCharactersPerLine, int expectedNumberOfLines)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var numberOfLines = 0;
var currentPosition = 0;
while (currentPosition < text.Length)
{
var textLine =
formatter.FormatLine(textSource, currentPosition, 1,
new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow));
if (text.Length - currentPosition > expectedCharactersPerLine)
{
Assert.Equal(expectedCharactersPerLine, textLine.TextRange.Length);
}
currentPosition += textLine.TextRange.Length;
numberOfLines++;
}
Assert.Equal(expectedNumberOfLines, numberOfLines);
}
}
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " +
"IndividualB2C, SingleOrg, or MultiOrg aren't used for &#8209;&#8209;auth."
, "Noto Sans", 40)]
[InlineData("01234 56789 01234 56789", "Noto Mono", 7)]
[Theory]
public void Should_Wrap(string text, string familyName, int numberOfCharactersPerLine)
{
using (Start())
{
var lineBreaker = new LineBreakEnumerator(text.AsMemory());
var expected = new List<int>();
while (lineBreaker.MoveNext())
{
expected.Add(lineBreaker.Current.PositionWrap - 1);
}
var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" +
familyName);
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
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 GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap));
Assert.True(expected.Contains(textLine.TextRange.End));
var index = expected.IndexOf(textLine.TextRange.End);
for (var i = 0; i <= index; i++)
{
expected.RemoveAt(0);
}
currentPosition += textLine.TextRange.Length;
}
}
}
[Fact]
public void Should_Produce_Fixed_Height_Lines()
{
using (Start())
{
const string text = "012345";
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties, lineHeight: 50));
Assert.Equal(50, textLine.LineMetrics.Size.Height);
}
}
public static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl()));
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl()));
return disposable;
}
}
}

145
tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs → tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

@ -4,15 +4,33 @@ using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Skia.UnitTests
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextLayoutTests
{
private static readonly string s_singleLineText = "0123456789";
private static readonly string s_multiLineText = "012345678\r\r0123456789";
[InlineData("01234\r01234\r", 3)]
[InlineData("01234\r01234", 2)]
[Theory]
public void Should_Break_Lines(string text, int numberOfLines)
{
using (Start())
{
var layout = new TextLayout(
text,
Typeface.Default,
12.0f,
Brushes.Black);
Assert.Equal(numberOfLines, layout.TextLines.Count);
}
}
[Fact]
public void Should_Apply_TextStyleSpan_To_Text_In_Between()
{
@ -22,17 +40,16 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(1, 2),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(1, 2,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
s_multiLineText,
Typeface.Default,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textStyleOverrides : spans);
textStyleOverrides: spans);
var textLine = layout.TextLines[0];
@ -46,7 +63,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("12", actual);
Assert.Equal(foreground, textRun.Style.Foreground);
Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
}
}
@ -61,9 +78,8 @@ namespace Avalonia.Skia.UnitTests
{
var spans = new[]
{
new TextStyleRun(
new TextPointer(0, i),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(0, i,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var expected = new TextLayout(
@ -72,22 +88,22 @@ namespace Avalonia.Skia.UnitTests
12.0f,
Brushes.Black.ToImmutable(),
textWrapping: TextWrapping.Wrap,
maxWidth : 25);
maxWidth: 25);
var actual = new TextLayout(
s_multiLineText,
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping : TextWrapping.Wrap,
maxWidth : 25,
textStyleOverrides : spans);
textWrapping: TextWrapping.Wrap,
maxWidth: 25,
textStyleOverrides: spans);
Assert.Equal(expected.TextLines.Count, actual.TextLines.Count);
for (var j = 0; j < actual.TextLines.Count; j++)
{
Assert.Equal(expected.TextLines[j].Text.Length, actual.TextLines[j].Text.Length);
Assert.Equal(expected.TextLines[j].TextRange.Length, actual.TextLines[j].TextRange.Length);
Assert.Equal(expected.TextLines[j].TextRuns.Sum(x => x.Text.Length),
actual.TextLines[j].TextRuns.Sum(x => x.Text.Length));
@ -105,9 +121,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(0, 2),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(0, 2,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
@ -115,7 +130,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textStyleOverrides : spans);
textStyleOverrides: spans);
var textLine = layout.TextLines[0];
@ -130,7 +145,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("01", actual);
Assert.Equal(foreground, textRun.Style.Foreground);
Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
}
}
@ -143,9 +158,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(8, 2),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(8, 2,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground)),
};
var layout = new TextLayout(
@ -153,7 +167,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textStyleOverrides : spans);
textStyleOverrides: spans);
var textLine = layout.TextLines[0];
@ -167,7 +181,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("89", actual);
Assert.Equal(foreground, textRun.Style.Foreground);
Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
}
}
@ -180,9 +194,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(0, 1),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(0, 1,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
@ -190,7 +203,7 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textStyleOverrides : spans);
textStyleOverrides: spans);
var textLine = layout.TextLines[0];
@ -200,7 +213,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal(1, textRun.Text.Length);
Assert.Equal(foreground, textRun.Style.Foreground);
Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
}
}
@ -215,9 +228,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(2, 2),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(2, 2,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
@ -239,7 +251,7 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal("😄", actual);
Assert.Equal(foreground, textRun.Style.Foreground);
Assert.Equal(foreground, textRun.Properties.ForegroundBrush);
}
}
@ -254,7 +266,7 @@ namespace Avalonia.Skia.UnitTests
12.0f,
Brushes.Black.ToImmutable());
Assert.Equal(s_multiLineText.Length, layout.TextLines.Sum(x => x.Text.Length));
Assert.Equal(s_multiLineText.Length, layout.TextLines.Sum(x => x.TextRange.Length));
}
}
@ -291,9 +303,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(0, 24),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(0, 24,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
@ -301,8 +312,8 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
textWrapping : TextWrapping.Wrap,
maxWidth : 180,
textWrapping: TextWrapping.Wrap,
maxWidth: 180,
textStyleOverrides: spans);
Assert.Equal(
@ -322,9 +333,8 @@ namespace Avalonia.Skia.UnitTests
var spans = new[]
{
new TextStyleRun(
new TextPointer(5, 20),
new TextStyle(Typeface.Default, 12, foreground))
new ValueSpan<TextRunProperties>(5, 20,
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: foreground))
};
var layout = new TextLayout(
@ -332,13 +342,13 @@ namespace Avalonia.Skia.UnitTests
Typeface.Default,
12.0f,
Brushes.Black.ToImmutable(),
maxWidth : 200,
maxHeight : 125,
maxWidth: 200,
maxHeight: 125,
textStyleOverrides: spans);
Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Style.Foreground);
Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Style.Foreground);
Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Style.Foreground);
Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Properties.ForegroundBrush);
Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Properties.ForegroundBrush);
Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Properties.ForegroundBrush);
}
}
@ -355,7 +365,7 @@ namespace Avalonia.Skia.UnitTests
12.0f,
Brushes.Black.ToImmutable());
var shapedRun = (ShapedTextRun)layout.TextLines[0].TextRuns[0];
var shapedRun = (ShapedTextCharacters)layout.TextLines[0].TextRuns[0];
var glyphRun = shapedRun.GlyphRun;
@ -390,7 +400,7 @@ namespace Avalonia.Skia.UnitTests
foreach (var textRun in textLine.TextRuns)
{
var shapedRun = (ShapedTextRun)textRun;
var shapedRun = (ShapedTextCharacters)textRun;
var glyphRun = shapedRun.GlyphRun;
@ -426,13 +436,13 @@ namespace Avalonia.Skia.UnitTests
Assert.Equal(1, layout.TextLines[0].TextRuns.Count);
Assert.Equal(expectedLength, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length);
Assert.Equal(expectedLength, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length);
Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]);
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]);
if(expectedLength == 7)
if (expectedLength == 7)
{
Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]);
Assert.Equal(5, ((ShapedTextCharacters)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]);
}
}
}
@ -467,7 +477,7 @@ namespace Avalonia.Skia.UnitTests
var textLine = layout.TextLines[0];
var textRun = (ShapedTextRun)textLine.TextRuns[0];
var textRun = (ShapedTextCharacters)textLine.TextRuns[0];
Assert.Equal(7, textRun.Text.Length);
@ -526,9 +536,28 @@ namespace Avalonia.Skia.UnitTests
}
}
[Fact]
public void Should_Produce_Fixed_Height_Lines()
{
using (Start())
{
var layout = new TextLayout(
s_multiLineText,
Typeface.Default,
12,
Brushes.Black,
lineHeight: 50);
foreach (var line in layout.TextLines)
{
Assert.Equal(50, line.LineMetrics.Size.Height);
}
}
}
private const string Text = "日本でTest一番読まれている英字新聞・ジャパンタイムズが発信する国内外ニュースと、様々なジャンルの特集記事。";
[Fact(Skip= "Only used for profiling.")]
[Fact(Skip = "Only used for profiling.")]
public void Should_Wrap()
{
using (Start())
@ -546,12 +575,12 @@ namespace Avalonia.Skia.UnitTests
}
}
public static IDisposable Start()
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl(),
fontManagerImpl : new CustomFontManagerImpl()));
fontManagerImpl: new CustomFontManagerImpl()));
return disposable;
}

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

@ -0,0 +1,175 @@
using System;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
public class TextLineTests
{
[InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("𐐷1234")]
[Theory]
public void Should_Get_Next_Caret_CharacterHit(string text)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
.ToArray();
var nextCharacterHit = new CharacterHit(0);
for (var i = 1; i < clusters.Length; i++)
{
nextCharacterHit = textLine.GetNextCaretCharacterHit(nextCharacterHit);
Assert.Equal(clusters[i], nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength);
}
}
}
[InlineData("𐐷𐐷𐐷𐐷𐐷")]
[InlineData("𐐷1234")]
[Theory]
public void Should_Get_Previous_Caret_CharacterHit(string text)
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new SingleBufferTextSource(text, defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters)
.ToArray();
var previousCharacterHit = new CharacterHit(clusters[^1]);
for (var i = clusters.Length - 2; i > 0; i--)
{
previousCharacterHit = textLine.GetPreviousCaretCharacterHit(previousCharacterHit);
Assert.Equal(clusters[i], previousCharacterHit.FirstCharacterIndex);
}
}
}
[Fact]
public void Should_Get_Distance_From_CharacterHit()
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var currentDistance = 0.0;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextCharacters)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentDistance, distance);
currentDistance += advance;
}
}
Assert.Equal(currentDistance,
textLine.GetDistanceFromCharacterHit(new CharacterHit(MultiBufferTextSource.TextRange.Length)));
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance()
{
using (Start())
{
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var textSource = new MultiBufferTextSource(defaultProperties);
var formatter = new TextFormatterImpl();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new GenericTextParagraphProperties(defaultProperties));
var currentDistance = 0.0;
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextCharacters)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
Assert.Equal(MultiBufferTextSource.TextRange.End, characterHit.FirstCharacterIndex);
}
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl(),
fontManagerImpl: new CustomFontManagerImpl()));
return disposable;
}
}
}

373
tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs

@ -1,373 +0,0 @@
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;
namespace Avalonia.Skia.UnitTests
{
public class SimpleTextFormatterTests
{
[Fact]
public void Should_Format_TextRuns_With_Default_Style()
{
using (Start())
{
const string text = "0123456789";
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textSource = new SimpleTextSource(text, defaultTextRunStyle);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Single(textLine.TextRuns);
var textRun = textLine.TextRuns[0];
Assert.Equal(defaultTextRunStyle.TextFormat, textRun.Style.TextFormat);
Assert.Equal(defaultTextRunStyle.Foreground, textRun.Style.Foreground);
Assert.Equal(text.Length, textRun.Text.Length);
}
}
[Fact]
public void Should_Format_TextRuns_With_Multiple_Buffers()
{
using (Start())
{
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textSource = new MultiBufferTextSource(defaultTextRunStyle);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity,
new TextParagraphProperties(defaultTextRunStyle));
Assert.Equal(5, textLine.TextRuns.Count);
Assert.Equal(50, textLine.Text.Length);
}
}
private class MultiBufferTextSource : ITextSource
{
private readonly string[] _runTexts;
private readonly TextStyle _defaultStyle;
public MultiBufferTextSource(TextStyle defaultStyle)
{
_defaultStyle = defaultStyle;
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" };
}
public TextPointer TextPointer => new TextPointer(0, 50);
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex == 50)
{
return new TextEndOfParagraph();
}
var index = textSourceIndex / 10;
var runText = _runTexts[index];
return new TextCharacters(
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle);
}
}
[Fact]
public void Should_Format_TextRuns_With_TextRunStyles()
{
using (Start())
{
const string text = "0123456789";
var defaultStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textStyleRuns = new[]
{
new TextStyleRun(new TextPointer(0, 3), defaultStyle ),
new TextStyleRun(new TextPointer(3, 3), new TextStyle(Typeface.Default, 13, Brushes.Black) ),
new TextStyleRun(new TextPointer(6, 3), new TextStyle(Typeface.Default, 14, Brushes.Black) ),
new TextStyleRun(new TextPointer(9, 1), defaultStyle )
};
var textSource = new FormattableTextSource(text, defaultStyle, textStyleRuns);
var formatter = new SimpleTextFormatter();
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(text.Length, textLine.Text.Length);
for (var i = 0; i < textStyleRuns.Length; i++)
{
var textStyleRun = textStyleRuns[i];
var textRun = textLine.TextRuns[i];
Assert.Equal(textStyleRun.TextPointer.Length, textRun.Text.Length);
}
}
}
private class FormattableTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultStyle;
private ReadOnlySlice<TextStyleRun> _textStyleRuns;
public FormattableTextSource(string text, TextStyle defaultStyle, ReadOnlySlice<TextStyleRun> textStyleRuns)
{
_text = text.AsMemory();
_defaultStyle = defaultStyle;
_textStyleRuns = textStyleRuns;
}
public TextRun GetTextRun(int textSourceIndex)
{
if (_textStyleRuns.IsEmpty)
{
return new TextEndOfParagraph();
}
var styleRun = _textStyleRuns[0];
_textStyleRuns = _textStyleRuns.Skip(1);
return new TextCharacters(_text.AsSlice(styleRun.TextPointer.Start, styleRun.TextPointer.Length),
_defaultStyle);
}
}
[Theory]
[InlineData("0123", 1)]
[InlineData("\r\n", 1)]
[InlineData("👍b", 2)]
[InlineData("a👍b", 3)]
[InlineData("a👍子b", 4)]
public void Should_Produce_Unique_Runs(string text, int numberOfRuns)
{
using (Start())
{
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(numberOfRuns, textLine.TextRuns.Count);
}
}
private class SimpleTextSource : ITextSource
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultTextStyle;
public SimpleTextSource(string text, TextStyle defaultText)
{
_text = text.AsMemory();
_defaultTextStyle = defaultText;
}
public TextRun GetTextRun(int textSourceIndex)
{
var runText = _text.Skip(textSourceIndex);
if (runText.IsEmpty)
{
return new TextEndOfParagraph();
}
return new TextCharacters(runText, _defaultTextStyle);
}
}
[Fact]
public void Should_Split_Run_On_Script()
{
using (Start())
{
const string text = "1234الدولي";
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
Assert.Equal(4, textLine.TextRuns[0].Text.Length);
}
}
[Fact]
public void Should_Get_Distance_From_CharacterHit()
{
using (Start())
{
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var currentDistance = 0.0;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentDistance, distance);
currentDistance += advance;
}
}
Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length)));
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance()
{
using (Start())
{
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var currentDistance = 0.0;
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var glyph = glyphRun.GlyphIndices[i];
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale;
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex);
}
}
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " +
"IndividualB2C, SingleOrg, or MultiOrg aren't used for &#8209;&#8209;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<int>();
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
.With(renderInterface: new PlatformRenderInterface(null),
textShaperImpl: new TextShaperImpl()));
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl()));
return disposable;
}
}
}

19
tests/Avalonia.UnitTests/MockTextShaperImpl.cs

@ -1,19 +1,19 @@
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using System;
using System.Globalization;
using Avalonia.Media;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utility;
using Avalonia.Utilities;
namespace Avalonia.UnitTests
{
public class MockTextShaperImpl : ITextShaperImpl
{
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat)
public GlyphRun ShapeText(ReadOnlySlice<char> text, Typeface typeface, double fontRenderingEmSize, CultureInfo culture)
{
var glyphTypeface = textFormat.Typeface.GlyphTypeface;
var glyphTypeface = typeface.GlyphTypeface;
var glyphIndices = new ushort[text.Length];
var height = textFormat.FontMetrics.LineHeight;
var width = 0.0;
var glyphCount = 0;
for (var i = 0; i < text.Length;)
{
@ -27,10 +27,11 @@ namespace Avalonia.UnitTests
glyphIndices[index] = glyph;
width += glyphTypeface.GetGlyphAdvance(glyph);
glyphCount++;
}
return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, glyphIndices, characters: text);
return new GlyphRun(glyphTypeface, fontRenderingEmSize,
new ReadOnlySlice<ushort>(glyphIndices.AsMemory(0, glyphCount)), characters: text);
}
}
}

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

@ -1,7 +1,7 @@
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.UnitTests;
using Avalonia.Utility;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Visuals.UnitTests.Media

2
tests/Avalonia.Visuals.UnitTests/Media/TextFormatting/LineBreakerTests.cs

@ -1,6 +1,6 @@
using System;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utility;
using Avalonia.Utilities;
using Xunit;
namespace Avalonia.Visuals.UnitTests.Media.Text

2
tests/Avalonia.Visuals.UnitTests/Media/TypefaceTests.cs

@ -9,7 +9,7 @@ namespace Avalonia.Visuals.UnitTests.Media
[Fact]
public void Exception_Should_Be_Thrown_If_FontWeight_LessThanEqualTo_Zero()
{
Assert.Throws<ArgumentException>(() => new Typeface("foo", 0, (FontStyle)12));
Assert.Throws<ArgumentException>(() => new Typeface("foo", (FontStyle)12, 0));
}
[Fact]

Loading…
Cancel
Save