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)