Skip to content

Commit 5846116

Browse files
Cache synthetic/nearest glyph typefaces under the requested family name (#21993)
* Cache synthetic/nearest glyph typefaces under the requested family name FontCollectionBase.TryGetGlyphTypeface only cached the resolved typeface under the requested family name when synthesis FAILED. When TryCreateSyntheticGlyphTypeface succeeds it registers the synthetic only under the source font's own family names, never under the name the caller asked for. A request arriving through a different name therefore never hits the cache and re-enters synthesis on every call. Synthesis goes through IPlatformTypeface.TryGetStream, which reads the entire font file into memory and hands it to SKTypeface.FromStream, which copies it again natively - so every resolution costs one retained copy of the whole font file. Two common shapes reach this path: - a platform alias, i.e. a family the font manager resolves but that is absent from GetInstalledFontFamilyNames(). Android declares several in /system/etc/fonts.xml (arial, helvetica, tahoma, verdana, times, courier); - a "Family Style" composite decomposed by Typeface.Normalize (e.g. "Arial Black" -> family arial + FontWeight.Black), which makes the requested key differ from the key the platform returns. Windows/DirectWrite is structurally immune: every resolvable family is also an installed family there, so the case-insensitive family-name search finds the synthetic (registered under its real name) on the second call and the missing entry repairs itself after one copy. With a platform alias nothing repairs it. Measured on Android 13 / arm64: a text widget set to "Arial Black" grew the native heap by +1187 MB over ~14 redraws (chunks of Roboto-Regular.ttf, identified by byte comparison), until the platform memory guard killed the process at 2.6 GB. Adds a regression test with an IFontManagerImpl that models a platform alias: without the fix the second resolution returns a different instance (re-synthesis), with it the cached one. * Make the regression test platform-independent Back the alias with an embedded test font instead of an installed Arial, and turn the test into a plain [Fact] so it runs on every platform. The defect being covered lives in FontCollectionBase and is platform-agnostic, so gating the test behind [Win32Fact] left it unexercised on the Linux and macOS legs of CI. Relying on an installed system font also made the test dependent on the build environment. The fake font manager now always resolves the alias to the regular face of an embedded font, whatever weight is asked for - which is what a platform alias actually does - so no system font is involved at all. * Make the regression test fail against unfixed code Name the backing font explicitly instead of taking whichever asset GetAssets enumerates first (currently AdobeBlank2VF.ttf, a blank variable font), matching the style used elsewhere for embedded test fonts. Assert that the first resolution really is a synthesised bold. Without it, a backing font that cannot be emboldened makes TryCreateSyntheticGlyphTypeface fail, the old else branch caches the nearest match, and the whole test passes against unfixed code. Verified both ways: FontCollectionTests is 4/4 with the fix, and Should_Cache_Synthetic_Match_Under_Requested_Family_Name is the single failure without it. --------- Co-authored-by: ronnycohen <19652995+ronnycohen@users.noreply.github.com> Co-authored-by: Benedikt Stebner <Gillibald@users.noreply.github.com>
1 parent b709c58 commit 5846116

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -852,11 +852,15 @@ protected bool TryGetGlyphTypeface(
852852
{
853853
glyphTypeface = syntheticGlyphTypeface;
854854
}
855-
else
856-
{
857-
// Cache the nearest match for future lookups
858-
TryAddGlyphTypeface(familyName, key, glyphTypeface);
859-
}
855+
856+
// Cache the resolved typeface under the REQUESTED family name, whether it is
857+
// the nearest match or a synthetic one. TryCreateSyntheticGlyphTypeface only
858+
// registers the synthetic under the *source font's own* family names, so a
859+
// request coming through a different name (an alias, or a "Family Style"
860+
// composite normalized by Typeface.Normalize) never hits the cache and
861+
// re-enters synthesis on every single call — and synthesis copies the whole
862+
// font file through TryGetStream.
863+
TryAddGlyphTypeface(familyName, key, glyphTypeface);
860864
}
861865

862866
return true;

tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Diagnostics.CodeAnalysis;
77
using System.Globalization;
8+
using System.IO;
89
using Avalonia.Media;
910
using Avalonia.Media.Fonts;
1011
using Avalonia.Platform;
@@ -148,5 +149,125 @@ public override bool TryCreateSyntheticGlyphTypeface(
148149
return base.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface);
149150
}
150151
}
152+
153+
[Fact]
154+
public void Should_Cache_Synthetic_Match_Under_Requested_Family_Name()
155+
{
156+
var fontManager = new AliasFontManagerImpl(alias: "MyAlias");
157+
158+
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: fontManager)))
159+
{
160+
var fontCollection = new TestSystemFontCollection(fontManager);
161+
var blackKey = new FontCollectionKey(FontStyle.Normal, FontWeight.Black, FontStretch.Normal);
162+
163+
// Prime the cache with the bare family, as any control asking for the alias at a
164+
// normal weight would. This is what makes the next lookup take the nearest match.
165+
Assert.True(fontCollection.TryGetGlyphTypeface(
166+
"MyAlias", FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out _));
167+
168+
Assert.True(fontCollection.TryGetGlyphTypeface(
169+
"MyAlias", FontStyle.Normal, FontWeight.Black, FontStretch.Normal, out var first));
170+
171+
// Guards the test itself: the first resolution must really be a synthesised bold.
172+
// If the backing font could not be emboldened, TryCreateSyntheticGlyphTypeface would
173+
// fail and the old else branch would cache the nearest match, making everything below
174+
// pass against unfixed code.
175+
Assert.Equal(FontSimulations.Bold, first.FontSimulations);
176+
177+
var creationsAfterFirstCall = fontManager.StreamTypefaceCreations;
178+
179+
for (var i = 0; i < 10; i++)
180+
{
181+
Assert.True(fontCollection.TryGetGlyphTypeface(
182+
"MyAlias", FontStyle.Normal, FontWeight.Black, FontStretch.Normal, out var next));
183+
184+
Assert.Same(first, next);
185+
}
186+
187+
// Each synthesis copies the entire font file through IPlatformTypeface.TryGetStream,
188+
// so an uncached synthetic means one full font copy per call.
189+
Assert.Equal(creationsAfterFirstCall, fontManager.StreamTypefaceCreations);
190+
191+
Assert.True(fontCollection.GlyphTypefaceCache.TryGetValue("MyAlias", out var cached));
192+
Assert.True(cached.ContainsKey(blackKey));
193+
}
194+
}
195+
196+
/// <summary>
197+
/// Font manager whose <c>MyAlias</c> family resolves through the platform but is absent from
198+
/// the installed family list — the shape of a platform alias (for instance Android's
199+
/// <c>&lt;alias name="arial" to="sans-serif"/&gt;</c> in <c>/system/etc/fonts.xml</c>).
200+
/// Such a family cannot be found again by the family-name search, so nothing repairs a
201+
/// missing cache entry.
202+
///
203+
/// The alias is backed by an embedded test font rather than an installed one, so the test
204+
/// runs identically on every platform.
205+
/// </summary>
206+
private sealed class AliasFontManagerImpl : IFontManagerImpl
207+
{
208+
/// <summary>Named explicitly rather than enumerated: the backing font must be a real
209+
/// text face, since a font that cannot be emboldened would make the test pass against
210+
/// unfixed code (the old else branch cached the nearest match).</summary>
211+
private const string BackingFontUri =
212+
"resm:Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf?assembly=Avalonia.Skia.UnitTests";
213+
214+
private readonly IFontManagerImpl _inner = new FontManagerImpl();
215+
private readonly string _alias;
216+
217+
public AliasFontManagerImpl(string alias)
218+
{
219+
_alias = alias;
220+
}
221+
222+
/// <summary>Number of typefaces created from a stream, i.e. of synthetic emboldenings.</summary>
223+
public int StreamTypefaceCreations { get; private set; }
224+
225+
public string GetDefaultFontFamilyName() => _inner.GetDefaultFontFamilyName();
226+
227+
public string[] GetInstalledFontFamilyNames(bool checkForUpdates = false)
228+
=> Array.Empty<string>();
229+
230+
public bool TryCreateGlyphTypeface(string familyName, FontStyle style, FontWeight weight,
231+
FontStretch stretch, [NotNullWhen(true)] out IPlatformTypeface? platformTypeface)
232+
{
233+
// The alias always resolves to the regular face of the backing font, never to the
234+
// requested weight — exactly what a platform alias does.
235+
if (string.Equals(familyName, _alias, StringComparison.OrdinalIgnoreCase))
236+
{
237+
using var stream = OpenBackingFont();
238+
239+
return _inner.TryCreateGlyphTypeface(stream, FontSimulations.None, out platformTypeface);
240+
}
241+
242+
platformTypeface = null;
243+
244+
return false;
245+
}
246+
247+
private static Stream OpenBackingFont()
248+
{
249+
var assetLoader = AvaloniaLocator.Current.GetRequiredService<IAssetLoader>();
250+
251+
return assetLoader.Open(new Uri(BackingFontUri, UriKind.Absolute));
252+
}
253+
254+
public bool TryCreateGlyphTypeface(Stream stream, FontSimulations fontSimulations,
255+
[NotNullWhen(true)] out IPlatformTypeface? platformTypeface)
256+
{
257+
StreamTypefaceCreations++;
258+
259+
return _inner.TryCreateGlyphTypeface(stream, fontSimulations, out platformTypeface);
260+
}
261+
262+
public bool TryGetFamilyTypefaces(string familyName,
263+
[NotNullWhen(true)] out IReadOnlyList<Typeface>? familyTypefaces)
264+
=> _inner.TryGetFamilyTypefaces(familyName, out familyTypefaces);
265+
266+
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
267+
FontStretch fontStretch, string? familyName, CultureInfo? culture,
268+
[NotNullWhen(true)] out IPlatformTypeface? platformTypeface)
269+
=> _inner.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName,
270+
culture, out platformTypeface);
271+
}
151272
}
152273
}

0 commit comments

Comments
 (0)