diff --git a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj
index d4abf9416ad..f5e502bca80 100644
--- a/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj
+++ b/tests/Avalonia.UnitTests/Avalonia.UnitTests.csproj
@@ -29,4 +29,5 @@
+
diff --git a/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs
new file mode 100644
index 00000000000..002da66070e
--- /dev/null
+++ b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Platform;
+
+namespace Avalonia.UnitTests
+{
+ public class HarfBuzzFontManagerImpl : IFontManagerImpl
+ {
+ private readonly Typeface[] _customTypefaces;
+ private readonly string _defaultFamilyName;
+
+ private static readonly Typeface _defaultTypeface =
+ new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono");
+ private static readonly Typeface _italicTypeface =
+ new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Sans");
+ private static readonly Typeface _emojiTypeface =
+ new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Twitter Color Emoji");
+
+ public HarfBuzzFontManagerImpl(string defaultFamilyName = "resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono")
+ {
+ _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface };
+ _defaultFamilyName = defaultFamilyName;
+ }
+
+ public string GetDefaultFontFamilyName()
+ {
+ return _defaultFamilyName;
+ }
+
+ public IEnumerable GetInstalledFontFamilyNames(bool checkForUpdates = false)
+ {
+ return _customTypefaces.Select(x => x.FontFamily!.Name);
+ }
+
+ public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontFamily fontFamily,
+ CultureInfo culture, out Typeface fontKey)
+ {
+ foreach (var customTypeface in _customTypefaces)
+ {
+ var glyphTypeface = customTypeface.GlyphTypeface;
+
+ if (!glyphTypeface.TryGetGlyph((uint)codepoint, out _))
+ {
+ continue;
+ }
+
+ fontKey = customTypeface;
+
+ return true;
+ }
+
+ fontKey = default;
+
+ return false;
+ }
+
+ public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface)
+ {
+ var fontFamily = typeface.FontFamily;
+
+ if (fontFamily == null)
+ {
+ return null;
+ }
+
+ if (fontFamily.IsDefault)
+ {
+ fontFamily = _defaultTypeface.FontFamily;
+ }
+
+ if (fontFamily!.Key == null)
+ {
+ return null;
+ }
+
+ var fontAssets = FontFamilyLoader.LoadFontAssets(fontFamily.Key);
+
+ var asset = fontAssets.First();
+
+ var assetLoader = AvaloniaLocator.Current.GetService();
+
+ if (assetLoader == null)
+ {
+ throw new NotSupportedException("IAssetLoader is not registered.");
+ }
+
+ var stream = assetLoader.Open(asset);
+
+ return new HarfBuzzGlyphTypefaceImpl(stream);
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs
new file mode 100644
index 00000000000..32e0434cd42
--- /dev/null
+++ b/tests/Avalonia.UnitTests/HarfBuzzGlyphTypefaceImpl.cs
@@ -0,0 +1,158 @@
+using System;
+using System.IO;
+using Avalonia.Platform;
+using HarfBuzzSharp;
+
+namespace Avalonia.UnitTests
+{
+ public class HarfBuzzGlyphTypefaceImpl : IGlyphTypefaceImpl
+ {
+ private bool _isDisposed;
+ private Blob _blob;
+
+ public HarfBuzzGlyphTypefaceImpl(Stream data, bool isFakeBold = false, bool isFakeItalic = false)
+ {
+ _blob = Blob.FromStream(data);
+
+ Face = new Face(_blob, 0);
+
+ Font = new Font(Face);
+
+ Font.SetFunctionsOpenType();
+
+ Font.GetScale(out var scale, out _);
+
+ DesignEmHeight = (short)scale;
+
+ var metrics = Font.OpenTypeMetrics;
+
+ const double defaultFontRenderingEmSize = 12.0;
+
+ Ascent = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalAscender) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ Descent = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalDescender) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ LineGap = (int)(metrics.GetXVariation(OpenTypeMetricsTag.HorizontalLineGap) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ UnderlinePosition = (int)(metrics.GetXVariation(OpenTypeMetricsTag.UnderlineOffset) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ UnderlineThickness = (int)(metrics.GetXVariation(OpenTypeMetricsTag.UnderlineSize) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ StrikethroughPosition = (int)(metrics.GetXVariation(OpenTypeMetricsTag.StrikeoutOffset) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ StrikethroughThickness = (int)(metrics.GetXVariation(OpenTypeMetricsTag.StrikeoutSize) / defaultFontRenderingEmSize * DesignEmHeight);
+
+ IsFixedPitch = GetGlyphAdvance(GetGlyph('a')) == GetGlyphAdvance(GetGlyph('b'));
+
+ IsFakeBold = isFakeBold;
+
+ IsFakeItalic = isFakeItalic;
+ }
+
+ public Face Face { get; }
+
+ public Font Font { get; }
+
+ ///
+ public short DesignEmHeight { get; }
+
+ ///
+ public int Ascent { get; }
+
+ ///
+ public int Descent { get; }
+
+ ///
+ public int LineGap { get; }
+
+ ///
+ public int UnderlinePosition { get; }
+
+ ///
+ public int UnderlineThickness { get; }
+
+ ///
+ public int StrikethroughPosition { get; }
+
+ ///
+ public int StrikethroughThickness { get; }
+
+ ///
+ public bool IsFixedPitch { get; }
+
+ public bool IsFakeBold { get; }
+
+ public bool IsFakeItalic { get; }
+
+ ///
+ public ushort GetGlyph(uint codepoint)
+ {
+ if (Font.TryGetGlyph(codepoint, out var glyph))
+ {
+ return (ushort)glyph;
+ }
+
+ return 0;
+ }
+
+ ///
+ public ushort[] GetGlyphs(ReadOnlySpan codepoints)
+ {
+ var glyphs = new ushort[codepoints.Length];
+
+ for (var i = 0; i < codepoints.Length; i++)
+ {
+ if (Font.TryGetGlyph(codepoints[i], out var glyph))
+ {
+ glyphs[i] = (ushort)glyph;
+ }
+ }
+
+ return glyphs;
+ }
+
+ ///
+ public int GetGlyphAdvance(ushort glyph)
+ {
+ return Font.GetHorizontalGlyphAdvance(glyph);
+ }
+
+ ///
+ public int[] GetGlyphAdvances(ReadOnlySpan glyphs)
+ {
+ var glyphIndices = new uint[glyphs.Length];
+
+ for (var i = 0; i < glyphs.Length; i++)
+ {
+ glyphIndices[i] = glyphs[i];
+ }
+
+ return Font.GetHorizontalGlyphAdvances(glyphIndices);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+
+ if (!disposing)
+ {
+ return;
+ }
+
+ Font?.Dispose();
+ Face?.Dispose();
+ _blob?.Dispose();
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs
new file mode 100644
index 00000000000..687fddd71a5
--- /dev/null
+++ b/tests/Avalonia.UnitTests/HarfBuzzTextShaperImpl.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Globalization;
+using Avalonia.Media;
+using Avalonia.Media.TextFormatting.Unicode;
+using Avalonia.Platform;
+using Avalonia.Utilities;
+using HarfBuzzSharp;
+using Buffer = HarfBuzzSharp.Buffer;
+
+namespace Avalonia.UnitTests
+{
+ public class HarfBuzzTextShaperImpl : ITextShaperImpl
+ {
+ public GlyphRun ShapeText(ReadOnlySlice text, Typeface typeface, double fontRenderingEmSize,
+ CultureInfo culture)
+ {
+ using (var buffer = new Buffer())
+ {
+ FillBuffer(buffer, text);
+
+ buffer.Language = new Language(culture ?? CultureInfo.CurrentCulture);
+
+ buffer.GuessSegmentProperties();
+
+ var glyphTypeface = typeface.GlyphTypeface;
+
+ var font = ((HarfBuzzGlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font;
+
+ font.Shape(buffer);
+
+ font.GetScale(out var scaleX, out _);
+
+ var textScale = fontRenderingEmSize / scaleX;
+
+ var bufferLength = buffer.Length;
+
+ var glyphInfos = buffer.GetGlyphInfoSpan();
+
+ var glyphPositions = buffer.GetGlyphPositionSpan();
+
+ var glyphIndices = new ushort[bufferLength];
+
+ var clusters = new ushort[bufferLength];
+
+ double[] glyphAdvances = null;
+
+ Vector[] glyphOffsets = null;
+
+ for (var i = 0; i < bufferLength; i++)
+ {
+ glyphIndices[i] = (ushort)glyphInfos[i].Codepoint;
+
+ clusters[i] = (ushort)glyphInfos[i].Cluster;
+
+ if (!glyphTypeface.IsFixedPitch)
+ {
+ SetAdvance(glyphPositions, i, textScale, ref glyphAdvances);
+ }
+
+ SetOffset(glyphPositions, i, textScale, ref glyphOffsets);
+ }
+
+ return new GlyphRun(glyphTypeface, fontRenderingEmSize,
+ new ReadOnlySlice(glyphIndices),
+ new ReadOnlySlice(glyphAdvances),
+ new ReadOnlySlice(glyphOffsets),
+ text,
+ new ReadOnlySlice(clusters),
+ buffer.Direction == Direction.LeftToRight ? 0 : 1);
+ }
+ }
+
+ private static void FillBuffer(Buffer buffer, ReadOnlySlice text)
+ {
+ buffer.ContentType = ContentType.Unicode;
+
+ var i = 0;
+
+ while (i < text.Length)
+ {
+ var codepoint = Codepoint.ReadAt(text, i, out var count);
+
+ var cluster = (uint)(text.Start + i);
+
+ if (codepoint.IsBreakChar)
+ {
+ if (i + 1 < text.Length)
+ {
+ var nextCodepoint = Codepoint.ReadAt(text, i + 1, out _);
+
+ if (nextCodepoint == '\n' && codepoint == '\r')
+ {
+ count++;
+
+ buffer.Add('\u200C', cluster);
+
+ buffer.Add('\u200D', cluster);
+ }
+ else
+ {
+ buffer.Add('\u200C', cluster);
+ }
+ }
+ else
+ {
+ buffer.Add('\u200C', cluster);
+ }
+ }
+ else
+ {
+ buffer.Add(codepoint, cluster);
+ }
+
+ i += count;
+ }
+ }
+
+ private static void SetOffset(ReadOnlySpan glyphPositions, int index, double textScale,
+ ref Vector[] offsetBuffer)
+ {
+ var position = glyphPositions[index];
+
+ if (position.XOffset == 0 && position.YOffset == 0)
+ {
+ return;
+ }
+
+ offsetBuffer ??= new Vector[glyphPositions.Length];
+
+ var offsetX = position.XOffset * textScale;
+
+ var offsetY = position.YOffset * textScale;
+
+ offsetBuffer[index] = new Vector(offsetX, offsetY);
+ }
+
+ private static void SetAdvance(ReadOnlySpan glyphPositions, int index, double textScale,
+ ref double[] advanceBuffer)
+ {
+ advanceBuffer ??= new double[glyphPositions.Length];
+
+ // Depends on direction of layout
+ // advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale;
+ advanceBuffer[index] = glyphPositions[index].XAdvance * textScale;
+ }
+ }
+}
diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs
index 1e586e3bb17..da678fd74b0 100644
--- a/tests/Avalonia.UnitTests/TestServices.cs
+++ b/tests/Avalonia.UnitTests/TestServices.cs
@@ -58,6 +58,12 @@ public class TestServices
public static readonly TestServices RealStyler = new TestServices(
styler: new Styler());
+ public static readonly TestServices TextServices = new TestServices(
+ assetLoader: new AssetLoader(),
+ renderInterface: new MockPlatformRenderInterface(),
+ fontManagerImpl: new HarfBuzzFontManagerImpl(),
+ textShaperImpl: new HarfBuzzTextShaperImpl());
+
public TestServices(
IAssetLoader assetLoader = null,
IFocusManager focusManager = null,
diff --git a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs
index 6e5b8eb637d..a48639a426b 100644
--- a/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs
+++ b/tests/Avalonia.Visuals.UnitTests/Media/FontManagerTests.cs
@@ -48,7 +48,16 @@ public void Should_Use_FontManagerOptions_DefaultFamilyName()
[Fact]
public void Should_Use_FontManagerOptions_FontFallback()
{
- var options = new FontManagerOptions { FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("MyFont"), UnicodeRange = UnicodeRange.Default} } };
+ var options = new FontManagerOptions
+ {
+ FontFallbacks = new[]
+ {
+ new FontFallback
+ {
+ FontFamily = new FontFamily("MyFont"), UnicodeRange = UnicodeRange.Default
+ }
+ }
+ };
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
.With(fontManagerImpl: new MockFontManagerImpl())))
diff --git a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
index 58feb4714ac..f52bdc39c8d 100644
--- a/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
+++ b/tests/Avalonia.Visuals.UnitTests/Media/GlyphRunTests.cs
@@ -22,6 +22,7 @@ public GlyphRunTests()
[Theory]
public void Should_Get_Distance_From_CharacterHit(double[] advances, ushort[] clusters, int start, int trailingLength, double expectedDistance)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters))
{
var characterHit = new CharacterHit(start, trailingLength);
@@ -40,6 +41,7 @@ public void Should_Get_Distance_From_CharacterHit(double[] advances, ushort[] cl
public void Should_Get_CharacterHit_FromDistance(double[] advances, ushort[] clusters, double distance, int start,
int trailingLengthExpected, bool isInsideExpected)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters))
{
var textBounds = glyphRun.GetCharacterHitFromDistance(distance, out var isInside);
@@ -63,6 +65,7 @@ public void Should_Get_CharacterHit_FromDistance(double[] advances, ushort[] clu
public void Should_Find_Nearest_CharacterHit(double[] advances, ushort[] clusters, int bidiLevel,
int index, int expectedIndex, int expectedLength, double expectedWidth)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
{
var textBounds = glyphRun.FindNearestCharacterHit(index, out var width);
@@ -87,6 +90,7 @@ public void Should_Get_Next_CharacterHit(double[] advances, ushort[] clusters,
int nextIndex, int nextLength,
int bidiLevel)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
{
var characterHit = glyphRun.GetNextCaretCharacterHit(new CharacterHit(currentIndex, currentLength));
@@ -109,6 +113,7 @@ public void Should_Get_Previous_CharacterHit(double[] advances, ushort[] cluster
int previousIndex, int previousLength,
int bidiLevel)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
{
var characterHit = glyphRun.GetPreviousCaretCharacterHit(new CharacterHit(currentIndex, currentLength));
@@ -128,6 +133,7 @@ public void Should_Get_Previous_CharacterHit(double[] advances, ushort[] cluster
[Theory]
public void Should_Find_Glyph_Index(double[] advances, ushort[] clusters, int bidiLevel)
{
+ using(UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
using (var glyphRun = CreateGlyphRun(advances, clusters, bidiLevel))
{
if (glyphRun.IsLeftToRight)