From 8f42370cc674bf044fc9e0902acf5d0104eae8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 22 Apr 2026 23:31:13 +0200 Subject: [PATCH 1/3] Add shared typeface cache --- .../TypefaceProviders/SharedTypefaceCache.cs | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/Svg.Skia/TypefaceProviders/SharedTypefaceCache.cs diff --git a/src/Svg.Skia/TypefaceProviders/SharedTypefaceCache.cs b/src/Svg.Skia/TypefaceProviders/SharedTypefaceCache.cs new file mode 100644 index 0000000000..b62ccbf163 --- /dev/null +++ b/src/Svg.Skia/TypefaceProviders/SharedTypefaceCache.cs @@ -0,0 +1,322 @@ +// Copyright (c) Wieslaw Soltes. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Concurrent; + +namespace Svg.Skia.TypefaceProviders; + +internal static class SharedTypefaceCache +{ + private const int ProviderTypefaceCacheLimit = 1024; + private const int ResolvedTypefaceCacheLimit = 1024; + private const int MatchCharacterCacheLimit = 4096; + + private enum ProviderKind + { + Default, + FontManager + } + + private sealed class TypefaceCacheEntry + { + public TypefaceCacheEntry(SkiaSharp.SKTypeface? typeface) + { + Typeface = typeface; + } + + public SkiaSharp.SKTypeface? Typeface { get; } + } + + private readonly struct ProviderTypefaceKey : IEquatable + { + public ProviderTypefaceKey( + ProviderKind providerKind, + IntPtr fontManagerHandle, + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant) + { + ProviderKind = providerKind; + FontManagerHandle = fontManagerHandle; + FamilyName = familyName; + Weight = weight; + Width = width; + Slant = slant; + } + + public ProviderKind ProviderKind { get; } + public IntPtr FontManagerHandle { get; } + public string FamilyName { get; } + public SkiaSharp.SKFontStyleWeight Weight { get; } + public SkiaSharp.SKFontStyleWidth Width { get; } + public SkiaSharp.SKFontStyleSlant Slant { get; } + + public bool Equals(ProviderTypefaceKey other) + { + return ProviderKind == other.ProviderKind && + FontManagerHandle == other.FontManagerHandle && + string.Equals(FamilyName, other.FamilyName, StringComparison.Ordinal) && + Weight == other.Weight && + Width == other.Width && + Slant == other.Slant; + } + + public override bool Equals(object? obj) + { + return obj is ProviderTypefaceKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hash = (int)ProviderKind; + hash = (hash * 397) ^ FontManagerHandle.GetHashCode(); + hash = (hash * 397) ^ FamilyName.GetHashCode(); + hash = (hash * 397) ^ (int)Weight; + hash = (hash * 397) ^ (int)Width; + hash = (hash * 397) ^ (int)Slant; + return hash; + } + } + } + + private readonly struct ResolvedTypefaceKey : IEquatable + { + public ResolvedTypefaceKey( + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant) + { + FamilyName = familyName; + Weight = weight; + Width = width; + Slant = slant; + } + + public string FamilyName { get; } + public SkiaSharp.SKFontStyleWeight Weight { get; } + public SkiaSharp.SKFontStyleWidth Width { get; } + public SkiaSharp.SKFontStyleSlant Slant { get; } + + public bool Equals(ResolvedTypefaceKey other) + { + return string.Equals(FamilyName, other.FamilyName, StringComparison.Ordinal) && + Weight == other.Weight && + Width == other.Width && + Slant == other.Slant; + } + + public override bool Equals(object? obj) + { + return obj is ResolvedTypefaceKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hash = FamilyName.GetHashCode(); + hash = (hash * 397) ^ (int)Weight; + hash = (hash * 397) ^ (int)Width; + hash = (hash * 397) ^ (int)Slant; + return hash; + } + } + } + + private readonly struct MatchCharacterKey : IEquatable + { + public MatchCharacterKey( + string? familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + int codepoint) + { + FamilyName = familyName; + Weight = weight; + Width = width; + Slant = slant; + Codepoint = codepoint; + } + + public string? FamilyName { get; } + public SkiaSharp.SKFontStyleWeight Weight { get; } + public SkiaSharp.SKFontStyleWidth Width { get; } + public SkiaSharp.SKFontStyleSlant Slant { get; } + public int Codepoint { get; } + + public bool Equals(MatchCharacterKey other) + { + return string.Equals(FamilyName, other.FamilyName, StringComparison.Ordinal) && + Weight == other.Weight && + Width == other.Width && + Slant == other.Slant && + Codepoint == other.Codepoint; + } + + public override bool Equals(object? obj) + { + return obj is MatchCharacterKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hash = FamilyName?.GetHashCode() ?? 0; + hash = (hash * 397) ^ (int)Weight; + hash = (hash * 397) ^ (int)Width; + hash = (hash * 397) ^ (int)Slant; + hash = (hash * 397) ^ Codepoint; + return hash; + } + } + } + + private static readonly ConcurrentDictionary s_providerTypefaceCache = new(); + private static readonly ConcurrentDictionary s_resolvedTypefaceCache = new(); + private static readonly ConcurrentDictionary s_matchCharacterCache = new(); + + public static bool TryGetOrAddProviderTypeface( + ITypefaceProvider provider, + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + out SkiaSharp.SKTypeface? typeface) + { + if (!TryCreateProviderTypefaceKey(provider, familyName, weight, width, slant, out var key)) + { + typeface = null; + return false; + } + + if (TryGetValidTypeface(s_providerTypefaceCache, key, out typeface)) + { + return true; + } + + typeface = provider.FromFamilyName(familyName, weight, width, slant); + typeface = GetValidTypefaceOrNull(typeface); + + s_providerTypefaceCache.TryAdd(key, new TypefaceCacheEntry(typeface)); + TrimCacheIfNeeded(s_providerTypefaceCache, ProviderTypefaceCacheLimit); + return true; + } + + public static bool TryGetResolvedTypeface( + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + out SkiaSharp.SKTypeface? typeface) + { + var key = new ResolvedTypefaceKey(familyName, weight, width, slant); + return TryGetValidTypeface(s_resolvedTypefaceCache, key, out typeface); + } + + public static void AddResolvedTypeface( + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + SkiaSharp.SKTypeface? typeface) + { + var key = new ResolvedTypefaceKey(familyName, weight, width, slant); + s_resolvedTypefaceCache.TryAdd(key, new TypefaceCacheEntry(GetValidTypefaceOrNull(typeface))); + TrimCacheIfNeeded(s_resolvedTypefaceCache, ResolvedTypefaceCacheLimit); + } + + public static bool TryGetMatchedCharacter( + string? familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + int codepoint, + out SkiaSharp.SKTypeface? typeface) + { + var key = new MatchCharacterKey(familyName, weight, width, slant, codepoint); + return TryGetValidTypeface(s_matchCharacterCache, key, out typeface); + } + + public static void AddMatchedCharacter( + string? familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + int codepoint, + SkiaSharp.SKTypeface? typeface) + { + var key = new MatchCharacterKey(familyName, weight, width, slant, codepoint); + s_matchCharacterCache.TryAdd(key, new TypefaceCacheEntry(GetValidTypefaceOrNull(typeface))); + TrimCacheIfNeeded(s_matchCharacterCache, MatchCharacterCacheLimit); + } + + private static bool TryCreateProviderTypefaceKey( + ITypefaceProvider provider, + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + out ProviderTypefaceKey key) + { + switch (provider) + { + case DefaultTypefaceProvider: + key = new ProviderTypefaceKey(ProviderKind.Default, IntPtr.Zero, familyName, weight, width, slant); + return true; + case FontManagerTypefaceProvider fontManagerProvider: + var handle = fontManagerProvider.FontManager?.Handle ?? IntPtr.Zero; + key = new ProviderTypefaceKey(ProviderKind.FontManager, handle, familyName, weight, width, slant); + return handle != IntPtr.Zero; + default: + key = default; + return false; + } + } + + private static SkiaSharp.SKTypeface? GetValidTypefaceOrNull(SkiaSharp.SKTypeface? typeface) + { + return typeface is { } && typeface.Handle == IntPtr.Zero + ? null + : typeface; + } + + private static bool TryGetValidTypeface( + ConcurrentDictionary cache, + TKey key, + out SkiaSharp.SKTypeface? typeface) + where TKey : notnull + { + if (!cache.TryGetValue(key, out var cached)) + { + typeface = null; + return false; + } + + typeface = cached.Typeface; + if (typeface is null || typeface.Handle != IntPtr.Zero) + { + return true; + } + + cache.TryRemove(key, out _); + typeface = null; + return false; + } + + private static void TrimCacheIfNeeded( + ConcurrentDictionary cache, + int limit) + where TKey : notnull + { + if (cache.Count > limit) + { + cache.Clear(); + } + } +} From 338c7854040ffad0ef46fd5001b0aa23bb683abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 22 Apr 2026 23:31:25 +0200 Subject: [PATCH 2/3] Reuse shared platform font caches --- src/Svg.Skia/SkiaModel.cs | 50 ++++++++++++++++++++++++++---- src/Svg.Skia/SkiaSvgAssetLoader.cs | 36 ++++++++++++++++++--- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/Svg.Skia/SkiaModel.cs b/src/Svg.Skia/SkiaModel.cs index c2a39a23da..90c74361b3 100644 --- a/src/Svg.Skia/SkiaModel.cs +++ b/src/Svg.Skia/SkiaModel.cs @@ -354,11 +354,14 @@ private void TrimTypefaceCachesIfNeeded() return null; } + var weight = (SkiaSharp.SKFontStyleWeight)style.Weight; + var width = (SkiaSharp.SKFontStyleWidth)style.Width; + var slant = (SkiaSharp.SKFontStyleSlant)style.Slant; var cacheKey = new TypefaceKey( candidate, - (SkiaSharp.SKFontStyleWeight)style.Weight, - (SkiaSharp.SKFontStyleWidth)style.Width, - (SkiaSharp.SKFontStyleSlant)style.Slant); + weight, + width, + slant); if (_resolvedTypefaceCache.TryGetValue(cacheKey, out var cached)) { if (cached is not null && cached.Handle != IntPtr.Zero) @@ -369,6 +372,17 @@ private void TrimTypefaceCachesIfNeeded() _resolvedTypefaceCache.TryRemove(cacheKey, out _); } + if (SharedTypefaceCache.TryGetResolvedTypeface(candidate, weight, width, slant, out var sharedCached)) + { + if (sharedCached is not null) + { + _resolvedTypefaceCache.TryAdd(cacheKey, sharedCached); + TrimTypefaceCachesIfNeeded(); + } + + return sharedCached; + } + var fontManager = SkiaSharp.SKFontManager.Default; var resolved = default(SkiaSharp.SKTypeface); @@ -406,9 +420,33 @@ private void TrimTypefaceCachesIfNeeded() _resolvedTypefaceCache.TryAdd(cacheKey, resolved); TrimTypefaceCachesIfNeeded(); } + + SharedTypefaceCache.AddResolvedTypeface(candidate, weight, width, slant, resolved); return resolved; } + private static SkiaSharp.SKTypeface? ResolveProviderTypeface( + ITypefaceProvider typefaceProvider, + string candidate, + SkiaSharp.SKFontStyleWeight fontWeight, + SkiaSharp.SKFontStyleWidth fontWidth, + SkiaSharp.SKFontStyleSlant fontStyle) + { + var typeface = SharedTypefaceCache.TryGetOrAddProviderTypeface( + typefaceProvider, + candidate, + fontWeight, + fontWidth, + fontStyle, + out var cached) + ? cached + : typefaceProvider.FromFamilyName(candidate, fontWeight, fontWidth, fontStyle); + + return typeface is { } && typeface.Handle == IntPtr.Zero + ? null + : typeface; + } + public SkiaSharp.SKTypeface? ToSKTypeface(SKTypeface? typeface) { var fontFamily = typeface?.FamilyName; @@ -437,7 +475,7 @@ private void TrimTypefaceCachesIfNeeded() { foreach (var typefaceProvider in Settings.TypefaceProviders) { - var providerTypeface = typefaceProvider.FromFamilyName(candidate, fontWeight, fontWidth, fontStyle); + var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { _typefaceCache.TryAdd(cacheKey, providerTypeface); @@ -464,7 +502,7 @@ private void TrimTypefaceCachesIfNeeded() { foreach (var typefaceProvider in Settings.TypefaceProviders) { - var providerTypeface = typefaceProvider.FromFamilyName(candidate, fontWeight, fontWidth, fontStyle); + var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { _typefaceCache.TryAdd(cacheKey, providerTypeface); @@ -488,7 +526,7 @@ private void TrimTypefaceCachesIfNeeded() { foreach (var typefaceProvider in Settings.TypefaceProviders) { - var providerTypeface = typefaceProvider.FromFamilyName(SkiaSharp.SKTypeface.Default.FamilyName, fontWeight, fontWidth, fontStyle); + var providerTypeface = ResolveProviderTypeface(typefaceProvider, SkiaSharp.SKTypeface.Default.FamilyName, fontWeight, fontWidth, fontStyle); if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { _typefaceCache.TryAdd(cacheKey, providerTypeface); diff --git a/src/Svg.Skia/SkiaSvgAssetLoader.cs b/src/Svg.Skia/SkiaSvgAssetLoader.cs index e396e6d0b1..66d973c739 100644 --- a/src/Svg.Skia/SkiaSvgAssetLoader.cs +++ b/src/Svg.Skia/SkiaSvgAssetLoader.cs @@ -405,7 +405,35 @@ private void TrimCachesIfNeeded() } var typeface = TryMatchCharacterFromCustomProviders(normalizedFamily, weight, width, slant, codepoint); - if (typeface is null && normalizedFamily is not null) + if (typeface is null) + { + if (!SharedTypefaceCache.TryGetMatchedCharacter(normalizedFamily, weight, width, slant, codepoint, out typeface)) + { + typeface = MatchPlatformCharacter(normalizedFamily, weight, width, slant, codepoint); + SharedTypefaceCache.AddMatchedCharacter(normalizedFamily, weight, width, slant, codepoint, typeface); + } + } + + if (typeface is { } && typeface.Handle == IntPtr.Zero) + { + typeface = null; + } + + _matchCharacterCache.TryAdd(key, typeface); + TrimCachesIfNeeded(); + return typeface; + } + + private static SkiaSharp.SKTypeface? MatchPlatformCharacter( + string? normalizedFamily, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + int codepoint) + { + var typeface = default(SkiaSharp.SKTypeface); + + if (normalizedFamily is not null) { foreach (var candidate in SkiaModel.EnumerateFontFamilyCandidates(normalizedFamily, browserCompatible: true)) { @@ -491,8 +519,6 @@ private void TrimCachesIfNeeded() typeface = null; } - _matchCharacterCache.TryAdd(key, typeface); - TrimCachesIfNeeded(); return typeface; } @@ -593,7 +619,9 @@ private static bool CanRenderAllCodepoints(SkiaSharp.SKTypeface? typeface, IRead _providerTypefaceCache.TryRemove(key, out _); } - var typeface = provider.FromFamilyName(familyName, weight, width, slant); + var typeface = SharedTypefaceCache.TryGetOrAddProviderTypeface(provider, familyName, weight, width, slant, out var sharedCached) + ? sharedCached + : provider.FromFamilyName(familyName, weight, width, slant); if (typeface is { } && typeface.Handle == IntPtr.Zero) { typeface = null; From cefe1e536418c405b8a8c5223d2e87c1d572f39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 22 Apr 2026 23:31:38 +0200 Subject: [PATCH 3/3] Cover custom provider cache isolation --- .../SkiaSvgAssetLoaderCachingTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs index cbf737dfd5..d3eb442293 100644 --- a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs +++ b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs @@ -1,9 +1,15 @@ #pragma warning disable CS0618 // Shim paint keeps deprecated SKPaint text/typeface surface for compatibility +using System.Collections.Generic; using System.Linq; using ShimSkiaSharp; using Svg.Skia; +using Svg.Skia.TypefaceProviders; using Xunit; +using NativeTypeface = SkiaSharp.SKTypeface; +using NativeTypefaceSlant = SkiaSharp.SKFontStyleSlant; +using NativeTypefaceWeight = SkiaSharp.SKFontStyleWeight; +using NativeTypefaceWidth = SkiaSharp.SKFontStyleWidth; namespace Svg.Skia.UnitTests; @@ -63,6 +69,33 @@ public void FindTypefaces_ReturnsIndependentResultsAndRecomputesAfterPaintMutati Assert.True(mutatedAdvance > repeatedAdvance * 2f); } + [Fact] + public void SharedCaches_DoNotBypassCustomTypefaceProvidersAcrossModels() + { + var firstProvider = new CountingTypefaceProvider(); + var secondProvider = new CountingTypefaceProvider(); + var requestedTypeface = SKTypeface.FromFamilyName( + "Missing Custom Family", + SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright); + + var firstModel = new SkiaModel(new SKSvgSettings + { + TypefaceProviders = new List { firstProvider } + }); + var secondModel = new SkiaModel(new SKSvgSettings + { + TypefaceProviders = new List { secondProvider } + }); + + firstModel.ToSKTypeface(requestedTypeface); + secondModel.ToSKTypeface(requestedTypeface); + + Assert.True(firstProvider.CallCount > 0); + Assert.True(secondProvider.CallCount > 0); + } + private static SKPaint CreateTextPaint(float textSize) { return new SKPaint @@ -75,6 +108,21 @@ private static SKPaint CreateTextPaint(float textSize) SKFontStyleSlant.Upright) }; } + + private sealed class CountingTypefaceProvider : ITypefaceProvider + { + public int CallCount { get; private set; } + + public NativeTypeface? FromFamilyName( + string fontFamily, + NativeTypefaceWeight fontWeight, + NativeTypefaceWidth fontWidth, + NativeTypefaceSlant fontStyle) + { + CallCount++; + return null; + } + } } #pragma warning restore CS0618