From baa1e0127723f8ffccd01b935c14a3b3dd670f5d Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Fri, 12 Jun 2026 09:37:33 +0200 Subject: [PATCH] Fix system font caching when retrieved with a different name (#21557) * Add failing cache test for font retrieved with different name * Fix system font caching when retrieved with a different name --- .../Media/Fonts/SystemFontCollection.cs | 4 + .../Media/FontManagerTests.cs | 106 ++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index c7b013729c..390f4f4719 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -67,6 +67,10 @@ namespace Avalonia.Media.Fonts //Add to cache with platform typeface family name first TryAddGlyphTypeface(platformTypeface.FamilyName, key, glyphTypeface); + + // Then the requested family name + if (familyName != platformTypeface.FamilyName) + TryAddGlyphTypeface(familyName, key, glyphTypeface); //Add to cache if (!TryAddGlyphTypeface(glyphTypeface)) diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index b0d6e1bfd1..4f171b29ad 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; using Avalonia.Fonts.Inter; using Avalonia.Logging; using Avalonia.Media; @@ -643,5 +646,108 @@ namespace Avalonia.Skia.UnitTests.Media Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("Inter", FontStyle.Normal, (FontWeight)650), out var nearestMatchTypeface)); Assert.Same(boldGlyphTypeface, nearestMatchTypeface); } + + [Fact] + public void TryGetGlyphTypeface_Should_Cache_Matched_GlyphTypeface_Under_Requested_FamilyName() + { + var fontManagerImpl = new FamilyRemappingFontManagerImpl("NotInstalled", "Noto Mono"); + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManagerImpl)); + + // "NotInstalled" is not installed, so the platform substitutes it with a different + // family ("Noto Mono"), much like requesting "Arial" yields "Liberation Sans" on some Linux distributions. + Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("NotInstalled"), out var first)); + Assert.Equal("Noto Mono", first.FamilyName); + + // The substitute should now be cached under the requested "NotInstalled" name, so a second + // lookup must resolve from the cache instead of asking the platform again. + Assert.True(FontManager.Current.TryGetGlyphTypeface(new Typeface("NotInstalled"), out var second)); + Assert.Same(first, second); + Assert.Equal(1, fontManagerImpl.RequestedFamilyCreateCount); + } + + /// + /// A font manager whose every by-name lookup resolves to a single matched font whose family name + /// differs from the requested one. + /// + private sealed class FamilyRemappingFontManagerImpl(string requestedFamilyName, string matchedFamilyName) + : IFontManagerImpl, IDisposable + { + public int RequestedFamilyCreateCount { get; private set; } + + public string GetDefaultFontFamilyName() + => matchedFamilyName; + + public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false) + => [matchedFamilyName]; + + public bool TryCreateGlyphTypeface( + string familyName, + FontStyle style, + FontWeight weight, + FontStretch stretch, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + if (string.Equals(familyName, requestedFamilyName, StringComparison.OrdinalIgnoreCase)) + RequestedFamilyCreateCount++; + + platformTypeface = new SkiaTypeface(CreateMatchedTypeface(), FontSimulations.None); + return true; + } + + public bool TryCreateGlyphTypeface( + Stream stream, + FontSimulations fontSimulations, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + platformTypeface = new SkiaTypeface(SKTypeface.FromStream(stream), fontSimulations); + return true; + } + + public bool TryMatchCharacter( + int codepoint, + FontStyle fontStyle, + FontWeight fontWeight, + FontStretch fontStretch, + string? familyName, + CultureInfo? culture, + [NotNullWhen(true)] out IPlatformTypeface? platformTypeface) + { + platformTypeface = null; + return false; + } + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + { + familyTypefaces = null; + return false; + } + + private SKTypeface CreateMatchedTypeface() + { + var assetLoader = AvaloniaLocator.Current.GetRequiredService(); + + // LoadFontAssets ignores the family fragment and returns every embedded font asset, + // so pick the one whose family name matches the substitute we want to return. + foreach (var fontAsset in FontFamilyLoader.LoadFontAssets(new Uri(s_fontUri))) + { + var stream = assetLoader.Open(fontAsset); + var typeface = SKTypeface.FromStream(stream); + + if (typeface is not null && + string.Equals(typeface.FamilyName, matchedFamilyName, StringComparison.OrdinalIgnoreCase)) + { + return typeface; + } + + typeface?.Dispose(); + } + + throw new InvalidOperationException($"Could not load the '{matchedFamilyName}' font asset."); + } + + public void Dispose() + { + } + } } }