From f174f5f255636e94c1ddfec28b03b46a811dc05a Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Sun, 24 Aug 2025 17:39:22 +0200 Subject: [PATCH] Fix FontCollection MatchCharacter (#19494) * Fix FontCollection.MatchCharacter usage inside the FontManager implementation Fix default MatchCharacter implementation Add a unit test * Reuse existing font collection key * Fix resm FontFamilyIdentifier * Fix FontFamily definition --------- Co-authored-by: Julien Lebosquain --- src/Avalonia.Base/Media/FontFamily.cs | 31 +++++++++++++---- src/Avalonia.Base/Media/FontManager.cs | 34 +++++++++++++------ .../Media/Fonts/EmbeddedFontCollection.cs | 4 --- .../Media/Fonts/FontCollectionBase.cs | 34 ++++++++++++------- src/Avalonia.Base/Utilities/UriExtensions.cs | 3 -- .../Media/CustomFontManagerImpl.cs | 4 +-- .../Media/FontCollectionTests.cs | 25 ++++++++------ .../Media/FontManagerTests.cs | 29 ++++++++++++++++ .../TextFormatting/TextFormatterTests.cs | 2 +- .../HarfBuzzFontManagerImpl.cs | 11 +++--- 10 files changed, 122 insertions(+), 55 deletions(-) diff --git a/src/Avalonia.Base/Media/FontFamily.cs b/src/Avalonia.Base/Media/FontFamily.cs index e1365bb87d..722f806cac 100644 --- a/src/Avalonia.Base/Media/FontFamily.cs +++ b/src/Avalonia.Base/Media/FontFamily.cs @@ -42,7 +42,9 @@ namespace Avalonia.Media if (fontSources.Count == 1) { - if(fontSources[0].Source is Uri source) + var singleSource = fontSources[0]; + + if (singleSource.Source is Uri source) { if (baseUri != null && !baseUri.IsAbsoluteUri) { @@ -51,6 +53,13 @@ namespace Avalonia.Media Key = new FontFamilyKey(source, baseUri); } + else + { + if(baseUri != null && baseUri.IsAbsoluteUri) + { + Key = new FontFamilyKey(baseUri); + } + } } else { @@ -141,11 +150,21 @@ namespace Avalonia.Media case 2: { - var source = innerSegments[0].StartsWith("/", StringComparison.Ordinal) - ? new Uri(innerSegments[0], UriKind.Relative) - : new Uri(innerSegments[0], UriKind.RelativeOrAbsolute); - - identifier = new FontSourceIdentifier(innerSegments[1].Trim(), source); + var path = innerSegments[0].Trim(); + var innerName = innerSegments[1].Trim(); + + if (string.IsNullOrEmpty(path)) + { + identifier = new FontSourceIdentifier(innerName, null); + } + else + { + var source = path.StartsWith("/", StringComparison.Ordinal) + ? new Uri(path, UriKind.Relative) + : new Uri(path, UriKind.RelativeOrAbsolute); + + identifier = new FontSourceIdentifier(innerName, source); + } break; } diff --git a/src/Avalonia.Base/Media/FontManager.cs b/src/Avalonia.Base/Media/FontManager.cs index 2671305222..7310ab19a1 100644 --- a/src/Avalonia.Base/Media/FontManager.cs +++ b/src/Avalonia.Base/Media/FontManager.cs @@ -271,21 +271,35 @@ namespace Avalonia.Media } //Try to match against fallbacks first - if (fontFamily != null && fontFamily.Key is CompositeFontFamilyKey compositeKey) + if (fontFamily?.Key != null) { - for (int i = 0; i < compositeKey.Keys.Count; i++) - { - var key = compositeKey.Keys[i]; - var familyName = fontFamily.FamilyNames[i]; - var source = key.Source.EnsureAbsolute(key.BaseUri); + var fontUri = fontFamily.Key.Source.EnsureAbsolute(fontFamily.Key.BaseUri); - if(familyName == FontFamily.DefaultFontFamilyName) + if (fontFamily.Key is CompositeFontFamilyKey compositeKey) + { + for (int i = 0; i < compositeKey.Keys.Count; i++) { - familyName = DefaultFontFamily.Name; + var key = compositeKey.Keys[i]; + var familyName = fontFamily.FamilyNames[i]; + var source = key.Source.EnsureAbsolute(key.BaseUri); + + if (familyName == FontFamily.DefaultFontFamilyName) + { + familyName = DefaultFontFamily.Name; + } + + if (TryGetFontCollection(source, out var fontCollection) && + fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface)) + { + return true; + } } + } - if (TryGetFontCollection(source, out var fontCollection) && - fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface)) + if (fontUri.IsFontCollection()) + { + if (TryGetFontCollection(fontUri, out var fontCollection) && + fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily.Name, culture, out typeface)) { return true; } diff --git a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs index 06f9e82858..1d706e9360 100644 --- a/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs +++ b/src/Avalonia.Base/Media/Fonts/EmbeddedFontCollection.cs @@ -15,8 +15,6 @@ namespace Avalonia.Media.Fonts private readonly Uri _source; - private IFontManagerImpl? _fontManager; - public EmbeddedFontCollection(Uri key, Uri source) { _key = key; @@ -32,8 +30,6 @@ namespace Avalonia.Media.Fonts public override void Initialize(IFontManagerImpl fontManager) { - _fontManager = fontManager; - var assetLoader = AvaloniaLocator.Current.GetRequiredService(); var fontAssets = FontFamilyLoader.LoadFontAssets(_source); diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs index a022fcfe4d..038332c2da 100644 --- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs +++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs @@ -27,29 +27,37 @@ namespace Avalonia.Media.Fonts string? familyName, CultureInfo? culture, out Typeface match) { match = default; - - if (string.IsNullOrEmpty(familyName)) + + //If a font family is defined we try to find a match inside that family first + if (familyName != null && _glyphTypefaceCache.TryGetValue(familyName, out var glyphTypefaces)) { - foreach (var typefaces in _glyphTypefaceCache.Values) + if (TryGetNearestMatch(glyphTypefaces, new FontCollectionKey { Style = style, Weight = weight, Stretch = stretch }, out var glyphTypeface)) { - if (TryGetNearestMatch(typefaces, new FontCollectionKey { Style = style, Weight = weight, Stretch = stretch }, out var glyphTypeface)) + if (glyphTypeface.TryGetGlyph((uint)codepoint, out _)) { - if (glyphTypeface.TryGetGlyph((uint)codepoint, out _)) - { - match = new Typeface(Key.AbsoluteUri + "#" + glyphTypeface.FamilyName, style, weight, stretch); + match = new Typeface(new FontFamily(Key, "#" + glyphTypeface.FamilyName), style, weight, stretch); - return true; - } + return true; } } } - else + + //Try to find a match in any font family + foreach (var pair in _glyphTypefaceCache) { - if (TryGetGlyphTypeface(familyName, style, weight, stretch, out var glyphTypeface)) + if(pair.Key == familyName) + { + //We already tried this before + continue; + } + + glyphTypefaces = pair.Value; + + if (TryGetNearestMatch(glyphTypefaces, new FontCollectionKey { Style = style, Weight = weight, Stretch = stretch }, out var glyphTypeface)) { - if (glyphTypeface.FamilyName.Contains(familyName) && glyphTypeface.TryGetGlyph((uint)codepoint, out _)) + if (glyphTypeface.TryGetGlyph((uint)codepoint, out _)) { - match = new Typeface(Key.AbsoluteUri + "#" + familyName, style, weight, stretch); + match = new Typeface(new FontFamily(Key, "#" + glyphTypeface.FamilyName) , style, weight, stretch); return true; } diff --git a/src/Avalonia.Base/Utilities/UriExtensions.cs b/src/Avalonia.Base/Utilities/UriExtensions.cs index 1f9c694eab..89c3487fb1 100644 --- a/src/Avalonia.Base/Utilities/UriExtensions.cs +++ b/src/Avalonia.Base/Utilities/UriExtensions.cs @@ -22,9 +22,6 @@ internal static class UriExtensions throw new ArgumentException($"Relative uri {uri} without base url"); if (!baseUri.IsAbsoluteUri) throw new ArgumentException($"Base uri {baseUri} is relative"); - if (baseUri.IsResm()) - throw new ArgumentException( - $"Relative uris for 'resm' scheme aren't supported; {baseUri} uses resm"); return new Uri(baseUri, uri); } diff --git a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs index e3db4a05ea..34dc32ac6b 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs @@ -18,10 +18,10 @@ namespace Avalonia.Skia.UnitTests.Media public CustomFontManagerImpl() { - _defaultFamilyName = "Noto Mono"; - var source = new Uri("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests"); + _defaultFamilyName = source.AbsoluteUri + "#Noto Mono"; + _customFonts = new EmbeddedFontCollection(source, source); } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs index 2dc8d7e772..5c830df4fa 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTests.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Avalonia.Media; using Avalonia.Media.Fonts; +using Avalonia.Platform; using Avalonia.UnitTests; using Xunit; @@ -72,7 +73,7 @@ namespace Avalonia.Skia.UnitTests.Media [Fact] public void Should_Use_Fallback() { - using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new CustomFontManagerImpl()))) { var source = new Uri(NotoMono, UriKind.Absolute); @@ -80,7 +81,7 @@ namespace Avalonia.Skia.UnitTests.Media var fontCollection = new CustomizableFontCollection(source, source, new[] { fallback }); - fontCollection.Initialize(new CustomFontManagerImpl()); + fontCollection.Initialize(FontManager.Current.PlatformImpl); Assert.True(fontCollection.TryMatchCharacter('A', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, null, null, out var match)); @@ -91,23 +92,25 @@ namespace Avalonia.Skia.UnitTests.Media [Fact] public void Should_Ignore_FontFamily() { - using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new CustomFontManagerImpl()))) { - var source = new Uri(NotoMono + "#Noto Mono", UriKind.Absolute); + var key = new Uri(NotoMono, UriKind.Absolute); var ignorable = new FontFamily(new Uri(NotoMono, UriKind.Absolute), "Noto Mono"); - var typeface = new Typeface(ignorable); + var fontCollection = new CustomizableFontCollection(key, key, null, new[] { ignorable }); + + fontCollection.Initialize(FontManager.Current.PlatformImpl); - var fontCollection = new CustomizableFontCollection(source, source, null, new[] { ignorable }); + var typeface = new Typeface(ignorable); - fontCollection.Initialize(new CustomFontManagerImpl()); + var glyphTypeface = typeface.GlyphTypeface; Assert.False(fontCollection.TryCreateSyntheticGlyphTypeface( - typeface.GlyphTypeface, - FontStyle.Italic, - FontWeight.DemiBold, - FontStretch.Normal, + typeface.GlyphTypeface, + FontStyle.Italic, + FontWeight.DemiBold, + FontStretch.Normal, out var syntheticGlyphTypeface)); } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index ac6da9c661..8057b81ace 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -6,6 +6,7 @@ using Avalonia.Fonts.Inter; using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.Fonts; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.UnitTests; using SkiaSharp; using Xunit; @@ -380,5 +381,33 @@ namespace Avalonia.Skia.UnitTests.Media } } } + + [Fact] + public void Should_Use_FontCollection_MatchCharacter() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()))) + { + using (AvaloniaLocator.EnterScope()) + { + FontManager.Current.AddFontCollection( + new EmbeddedFontCollection( + new Uri("fonts:MyCollection"), //key + new Uri("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests"))); //source + + var fontFamily = new FontFamily("fonts:MyCollection#Noto Mono"); + + var character = "א"; + + var codepoint = Codepoint.ReadAt(character, 0, out _); + + Assert.True(FontManager.Current.TryMatchCharacter(codepoint, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, fontFamily, null, out var typeface)); + + //Typeface should come from the font collection + Assert.NotNull(typeface.FontFamily.Key); + + Assert.Equal("Noto Sans Hebrew", typeface.GlyphTypeface.FamilyName); + } + } + } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index d1a406cbfc..ad9b91984c 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -1095,7 +1095,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { var text = "𖾇"; - var typeface = new Typeface(new FontFamily(new Uri("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests"), "Noto Mono")); + var typeface = new Typeface(new FontFamily(new Uri("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests"), "Noto Mono")); var defaultRunProperties = new GenericTextRunProperties(typeface); var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, textWrapping: TextWrapping.Wrap); var textLine = TextFormatter.Current.FormatLine(new SimpleTextSource(text, defaultRunProperties), 0, 120, paragraphProperties); diff --git a/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs index 3ec77307ef..78895912db 100644 --- a/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs +++ b/tests/Avalonia.UnitTests/HarfBuzzFontManagerImpl.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -13,13 +14,13 @@ namespace Avalonia.UnitTests private readonly string _defaultFamilyName; private static readonly Typeface _defaultTypeface = - new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono"); + new Typeface(new FontFamily(new Uri("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests", UriKind.Absolute), "Noto Mono")); private static readonly Typeface _italicTypeface = - new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Sans"); + new Typeface(new FontFamily(new Uri("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests", UriKind.Absolute), "Noto Sans")); private static readonly Typeface _emojiTypeface = - new Typeface("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Twitter Color Emoji"); + new Typeface(new FontFamily(new Uri("resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests"), "Twitter Color Emoji")); - public HarfBuzzFontManagerImpl(string defaultFamilyName = "resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono") + public HarfBuzzFontManagerImpl(string defaultFamilyName = "Noto Mono") { _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; _defaultFamilyName = defaultFamilyName;