diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs index 7ff8df99516..833bf39a30d 100644 --- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs @@ -30,42 +30,39 @@ public override bool TryGetGlyphTypeface(string familyName, FontStyle style, Fon { var typeface = new Typeface(familyName, style, weight, stretch).Normalize(out familyName); - if (base.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) - { - return true; - } - + // Use normalized values for cache lookup to ensure consistent cache keys style = typeface.Style; - weight = typeface.Weight; - stretch = typeface.Stretch; var key = new FontCollectionKey(style, weight, stretch); - //Check cache first to avoid unnecessary calls to the font manager + // Check cache first if (_glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces) && glyphTypefaces.TryGetValue(key, out glyphTypeface)) { return glyphTypeface != null; } - //Try to create the glyph typeface via system font manager - if (!_platformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) + // Try base class (handles nearest match and synthetic creation) + if (base.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) { - //Add null to cache to avoid future calls - TryAddGlyphTypeface(familyName, key, null); - - return false; + // Cache under requested key to prevent leaks when FamilyName differs + TryAddGlyphTypeface(familyName, key, glyphTypeface); + return true; } - //Add to cache - if (!TryAddGlyphTypeface(glyphTypeface)) + // Create via platform + if (!_platformImpl.TryCreateGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface)) { + TryAddGlyphTypeface(familyName, key, null); return false; } - //Requested glyph typeface should be in cache now - return base.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); + // Cache under requested key (prevents leaks) and actual properties + TryAddGlyphTypeface(familyName, key, glyphTypeface); + TryAddGlyphTypeface(glyphTypeface); + + return true; } public override bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs index 9aae98efe91..818621147c0 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs @@ -48,6 +48,95 @@ public TestSystemFontCollection(IFontManagerImpl platformImpl) : base(platformIm public IDictionary> GlyphTypefaceCache => _glyphTypefaceCache; } + /// + /// Verifies no leak when the returned typeface's FamilyName differs from requested. + /// This happens with fonts like "Segoe UI Variable Text" returning FamilyName="Segoe UI Variable". + /// + [Fact] + public void Should_Not_Leak_When_Typeface_FamilyName_Differs_From_Requested() + { + var fontManager = new MockFontManagerImpl(returnDifferentFamilyName: true); + + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManager))) + { + var fontCollection = new TestSystemFontCollection(fontManager); + + // First request creates the typeface + fontCollection.TryGetGlyphTypeface("TestFont", FontStyle.Normal, FontWeight.Bold, FontStretch.Normal, out _); + var countAfterFirst = fontManager.CreateCount; + + // Subsequent requests should hit cache, not create new typefaces + for (int i = 0; i < 100; i++) + { + fontCollection.TryGetGlyphTypeface("TestFont", FontStyle.Normal, FontWeight.Bold, FontStretch.Normal, out _); + } + + Assert.Equal(countAfterFirst, fontManager.CreateCount); + } + } + + private class MockFontManagerImpl : IFontManagerImpl + { + public int CreateCount { get; private set; } + private readonly bool _returnDifferentFamilyName; + + public MockFontManagerImpl(bool returnDifferentFamilyName) => _returnDifferentFamilyName = returnDifferentFamilyName; + + public string GetDefaultFontFamilyName() => "TestFont"; + public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false) => new[] { "TestFont" }; + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, + FontStretch fontStretch, string? familyName, CultureInfo? culture, out Typeface typeface) + { + typeface = new Typeface("TestFont"); + return true; + } + + public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight, + FontStretch stretch, [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + CreateCount++; + var returnedName = _returnDifferentFamilyName ? familyName + " UI" : familyName; + glyphTypeface = new MockGlyphTypeface(returnedName, style, weight, stretch); + return true; + } + + public bool TryCreateGlyphTypeface(System.IO.Stream stream, FontSimulations fontSimulations, + [NotNullWhen(true)] out IGlyphTypeface? glyphTypeface) + { + glyphTypeface = new MockGlyphTypeface("TestFont", FontStyle.Normal, FontWeight.Normal, FontStretch.Normal); + return true; + } + } + + private class MockGlyphTypeface : IGlyphTypeface + { + public MockGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch) + { + FamilyName = familyName; + Style = style; + Weight = weight; + Stretch = stretch; + } + + public string FamilyName { get; } + public FontStyle Style { get; } + public FontWeight Weight { get; } + public FontStretch Stretch { get; } + public int GlyphCount => 1; + public FontMetrics Metrics => new FontMetrics { DesignEmHeight = 1000, Ascent = -800, Descent = 200 }; + public FontSimulations FontSimulations => FontSimulations.None; + + public ushort GetGlyph(uint codepoint) => 1; + public bool TryGetGlyph(uint codepoint, out ushort glyph) { glyph = 1; return true; } + public int GetGlyphAdvance(ushort glyph) => 500; + public int[] GetGlyphAdvances(ReadOnlySpan glyphs) => new int[glyphs.Length]; + public ushort[] GetGlyphs(ReadOnlySpan codepoints) => new ushort[codepoints.Length]; + public bool TryGetGlyphMetrics(ushort glyph, out GlyphMetrics metrics) { metrics = default; return true; } + public bool TryGetTable(uint tag, out byte[] table) { table = Array.Empty(); return false; } + public void Dispose() { } + } + [Fact] public void Should_Use_Fallback() {