diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index 3b0c71ce20..948c3918f5 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -162,17 +162,20 @@ namespace Avalonia.Media.Fonts public override bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch, string? familyName, CultureInfo? culture, out Typeface match) { - //TODO12: Think about removing familyName parameter - match = default; + // First try to match via base implementation + if (base.TryMatchCharacter(codepoint, style, weight, stretch, familyName, culture, out match)) + { + return true; + } if (_fontManager.PlatformImpl is IFontManagerImpl2 fontManagerImpl2) { if (fontManagerImpl2.TryMatchCharacter(codepoint, style, weight, stretch, culture, out var glyphTypeface)) { - AddGlyphTypefaceByFamilyName(glyphTypeface.FamilyName, glyphTypeface); + match = GetImplicitTypeface(new Typeface(glyphTypeface.FamilyName, glyphTypeface.Style, glyphTypeface.Weight, + glyphTypeface.Stretch), out var matchedFamilyName); - match = new Typeface(glyphTypeface.FamilyName, glyphTypeface.Style, glyphTypeface.Weight, - glyphTypeface.Stretch); + AddGlyphTypefaceByFamilyName(matchedFamilyName, glyphTypeface); return true; } diff --git a/tests/Avalonia.RenderTests/Assets/NotoSansTamil-Regular.ttf b/tests/Avalonia.RenderTests/Assets/NotoSansTamil-Regular.ttf new file mode 100644 index 0000000000..1776798018 Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/NotoSansTamil-Regular.ttf differ diff --git a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs index 34dc32ac6b..c8b3ce34eb 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs @@ -7,14 +7,16 @@ using Avalonia.Platform; using SkiaSharp; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Collections.Generic; namespace Avalonia.Skia.UnitTests.Media { - public class CustomFontManagerImpl : IFontManagerImpl, IDisposable + public class CustomFontManagerImpl : IFontManagerImpl2, IDisposable { - private readonly string _defaultFamilyName; + protected readonly string _defaultFamilyName; private readonly IFontCollection _customFonts; private bool _isInitialized; + protected int _tryMatchCharacterCount; public CustomFontManagerImpl() { @@ -25,6 +27,8 @@ namespace Avalonia.Skia.UnitTests.Media _customFonts = new EmbeddedFontCollection(source, source); } + public int TryMatchCharacterCount => _tryMatchCharacterCount; + public string GetDefaultFontFamilyName() { return _defaultFamilyName; @@ -39,7 +43,7 @@ namespace Avalonia.Skia.UnitTests.Media _isInitialized = true; } - return _customFonts.Select(x=> x.Name).ToArray(); + return _customFonts.Select(x => x.Name).ToArray(); } private readonly string[] _bcp47 = { CultureInfo.CurrentCulture.ThreeLetterISOLanguageName, CultureInfo.CurrentCulture.TwoLetterISOLanguageName }; @@ -47,12 +51,14 @@ namespace Avalonia.Skia.UnitTests.Media public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo culture, out Typeface typeface) { + _tryMatchCharacterCount++; + if (!_isInitialized) { _customFonts.Initialize(this); } - if(_customFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, null, culture, out typeface)) + if (_customFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, null, culture, out typeface)) { return true; } @@ -99,5 +105,43 @@ namespace Avalonia.Skia.UnitTests.Media { _customFonts.Dispose(); } + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo culture, [NotNullWhen(true)] out IGlyphTypeface glyphTypeface) + { + _tryMatchCharacterCount++; + + if (!_isInitialized) + { + _customFonts.Initialize(this); + } + + if (_customFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, null, culture, out var typeface)) + { + glyphTypeface = typeface.GlyphTypeface; + + return true; + } + + var fallback = SKFontManager.Default.MatchCharacter(null, (SKFontStyleWeight)fontWeight, + (SKFontStyleWidth)fontStretch, (SKFontStyleSlant)fontStyle, _bcp47, codepoint); + + if (fallback == null) + { + glyphTypeface = null; + + return false; + } + + glyphTypeface = new GlyphTypefaceImpl(fallback, FontSimulations.None); + + return true; + } + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList familyTypefaces) + { + familyTypefaces = null; + + return false; + } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs index 5c830df4fa..81653f4a9e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using Avalonia.Media; using Avalonia.Media.Fonts; using Avalonia.Platform; @@ -115,6 +116,61 @@ namespace Avalonia.Skia.UnitTests.Media } } + [Fact] + public void SystemFontCollection_Only_Calls_Platform_TryMatchCharacter_Once_On_Success() + { + var countingImpl = new CustomFontManagerImpl(); + + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: countingImpl))) + { + var fontManager = FontManager.Current; + + var systemFonts = fontManager.SystemFonts as SystemFontCollection; + + Assert.NotNull(systemFonts); + + // First call should invoke platform TryMatchCharacter and populate cache + Assert.True(systemFonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var match1)); + + // Second call should be served from cache and should not call platform TryMatchCharacter again + Assert.True(systemFonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var match2)); + + Assert.Equal(1, countingImpl.TryMatchCharacterCount); + + Assert.Equal(match1.FontFamily.Name, match2.FontFamily.Name); + } + } + + [Fact] + public void Should_Cache_Font_By_Normalized_Name_When_Platform_Returns_Regular_Suffix() + { + var impl = new RegularSuffixFontManagerImpl("Default"); + + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: impl))) + { + var fontManager = FontManager.Current; + + var systemFonts = new TestSystemFontCollection(fontManager); + + Assert.NotNull(systemFonts); + + // Call TryMatchCharacter which should invoke platform TryMatchCharacter and add to cache + Assert.True(systemFonts.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var match)); + + var normalized = fontManager.DefaultFontFamily.Name; + + // Ensure the cache contains the normalized name (without 'Regular') + Assert.True(systemFonts.GlyphTypefaceCache.ContainsKey(normalized)); + + // Ensure the raw returned name with ' Regular' is not used as cache key + Assert.False(systemFonts.GlyphTypefaceCache.ContainsKey(normalized + " Regular")); + + Assert.True(systemFonts.TryGetGlyphTypeface(normalized + " Regular", FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out var cachedGlyphTypeface)); + + Assert.Equal(match.FontFamily.Name, cachedGlyphTypeface.FamilyName); + } + } + private class CustomizableFontCollection : EmbeddedFontCollection { private readonly IReadOnlyList? _fallbacks; @@ -174,5 +230,128 @@ namespace Avalonia.Skia.UnitTests.Media return base.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); } } + + private class RegularSuffixFontManagerImpl : IFontManagerImpl2 + { + private readonly string _defaultFamilyName; + + public RegularSuffixFontManagerImpl(string defaultFamilyName) + { + _defaultFamilyName = defaultFamilyName; + } + + public int TryMatchCharacterCount { get; private set; } + + public string GetDefaultFontFamilyName() => _defaultFamilyName; + + public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false) => new[] { _defaultFamilyName }; + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, out Typeface typeface) + { + if (TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, culture, out IGlyphTypeface? glyphTypeface)) + { + typeface = new Typeface(glyphTypeface.FamilyName, fontStyle, fontWeight); + + return true; + } + + typeface = default; + + return false; + } + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, CultureInfo? culture, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + TryMatchCharacterCount++; + + // Return a glyph typeface with ' Regular' appended so it will be normalized + glyphTypeface = new SimpleGlyphTypeface(_defaultFamilyName + " Regular", fontStyle, fontWeight, fontStretch); + + return true; + } + + public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + glyphTypeface = null; + return false; + } + + public bool TryCreateGlyphTypeface(Stream stream, FontSimulations fontSimulations, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + glyphTypeface = null; + return false; + } + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + { + familyTypefaces = null; + + return false; + } + } + + // Minimal IGlyphTypeface implementation for testing + private class SimpleGlyphTypeface : IGlyphTypeface + { + public SimpleGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch) + { + FamilyName = familyName; + Style = style; + Weight = weight; + Stretch = stretch; + } + + public FontMetrics Metrics => new FontMetrics { DesignEmHeight = 10, Ascent = 5, Descent = 3, LineGap = 0, IsFixedPitch = false }; + + public int GlyphCount => 1; + + public FontSimulations FontSimulations => FontSimulations.None; + + public string FamilyName { get; } + + public FontWeight Weight { get; } + + public FontStyle Style { get; } + + public FontStretch Stretch { get; } + + public void Dispose() { } + + public ushort GetGlyph(uint codepoint) => 1; + + public bool TryGetGlyph(uint codepoint, out ushort glyph) + { + glyph = 1; + return true; + } + + public int GetGlyphAdvance(ushort glyph) => 1; + + public int[] GetGlyphAdvances(ReadOnlySpan glyphs) + { + var arr = new int[glyphs.Length]; + for (var i = 0; i < arr.Length; i++) arr[i] = 1; + return arr; + } + + public ushort[] GetGlyphs(ReadOnlySpan codepoints) + { + var arr = new ushort[codepoints.Length]; + for (var i = 0; i < arr.Length; i++) arr[i] = 1; + return arr; + } + + public bool TryGetTable(uint tag, out byte[] table) + { + table = null!; + return false; + } + + public bool TryGetGlyphMetrics(ushort glyph, out GlyphMetrics metrics) + { + metrics = new GlyphMetrics { Width = 1, Height = 1 }; + return true; + } + } } }