From a08e125a14141a743fea423a46db85a7107a7143 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Wed, 25 Feb 2026 16:02:53 +0000 Subject: [PATCH] Handle font cmap format 13 (#20740) * Handle font cmap format 13 * Fallback to last resort fonts as... a last resort * Tests all fonts in Should_AddGlyphTypeface_By_Stream * Add last resort font test * Remove cmap subtable 13 from TestFontNoCmap412.ttf --- .../Media/Fonts/FontCollectionBase.cs | 106 ++++++++++++------ .../Fonts/Tables/Cmap/CharacterToGlyphMap.cs | 39 ++++--- ...mat12Table.cs => CmapFormat12Or13Table.cs} | 46 ++++---- .../Media/Fonts/Tables/Cmap/CmapTable.cs | 16 ++- .../Tables/Cmap/CodepointRangeEnumerator.cs | 15 +-- src/Avalonia.Base/Media/GlyphTypeface.cs | 8 ++ .../Media/GlyphTypefaceTests.cs | 23 +++- .../Assets/AdobeBlank2VF.ttf | Bin 0 -> 2896 bytes .../Fonts/TestFontNoCmap412.ttf | Bin 2788 -> 2808 bytes .../Media/CustomFontCollectionTests.cs | 61 ++++++---- .../Media/FontManagerTests.cs | 35 ++++++ 11 files changed, 236 insertions(+), 113 deletions(-) rename src/Avalonia.Base/Media/Fonts/Tables/Cmap/{CmapFormat12Table.cs => CmapFormat12Or13Table.cs} (88%) create mode 100644 tests/Avalonia.RenderTests/Assets/AdobeBlank2VF.ttf diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs index a3375652b8..e2076d34b6 100644 --- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs +++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using Avalonia.Media.Fonts.Tables; using Avalonia.Platform; namespace Avalonia.Media.Fonts @@ -55,35 +56,40 @@ namespace Avalonia.Media.Fonts } } - //Try to find a match in any font family - foreach (var pair in _glyphTypefaceCache) + return TryMatchInAnyFamily(isLastResort: false, out match) || + TryMatchInAnyFamily(isLastResort: true, out match); + + bool TryMatchInAnyFamily(bool isLastResort, out Typeface match) { - if (pair.Key == familyName) + //Try to find a match in any font family + foreach (var pair in _glyphTypefaceCache) { - //We already tried this before - continue; - } - - glyphTypefaces = pair.Value; + if (pair.Key == familyName) + { + //We already tried this before + continue; + } - if (TryGetNearestMatch(glyphTypefaces, key, out var glyphTypeface)) - { - if (glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) + if (TryGetNearestMatchCore(pair.Value, key, isLastResort, out var glyphTypeface)) { - var platformTypeface = glyphTypeface.PlatformTypeface; + if (glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) + { + var platformTypeface = glyphTypeface.PlatformTypeface; - // Found a match - match = new Typeface(new FontFamily(null, Key.AbsoluteUri + "#" + glyphTypeface.FamilyName), - platformTypeface.Style, - platformTypeface.Weight, - platformTypeface.Stretch); + // Found a match + match = new Typeface(new FontFamily(null, Key.AbsoluteUri + "#" + glyphTypeface.FamilyName), + platformTypeface.Style, + platformTypeface.Weight, + platformTypeface.Stretch); - return true; + return true; + } } } - } - return false; + match = default; + return false; + } } public virtual bool TryCreateSyntheticGlyphTypeface( @@ -558,7 +564,7 @@ namespace Avalonia.Media.Fonts /// provided collection of glyph typefaces. /// /// This method attempts to find the best match for the specified font key by considering - /// various fallback strategies, such as normalizing the font style, stretch, and weight. + /// various fallback strategies, such as normalizing the font style, stretch, and weight. /// If no suitable match is found, the method will return the first available non-null from the /// collection, if any. /// A collection of glyph typefaces, indexed by . @@ -567,10 +573,22 @@ namespace Avalonia.Media.Fonts /// key, if a match is found; otherwise, . /// if a matching is found; otherwise, . - protected bool TryGetNearestMatch(IDictionary glyphTypefaces, + protected bool TryGetNearestMatch(IDictionary glyphTypefaces, FontCollectionKey key, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) { - if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && glyphTypeface != null) + return TryGetNearestMatchCore(glyphTypefaces, key, isLastResort: false, out glyphTypeface) + || TryGetNearestMatchCore(glyphTypefaces, key, isLastResort: true, out glyphTypeface); + } + + private static bool TryGetNearestMatchCore( + IDictionary glyphTypefaces, + FontCollectionKey key, + bool isLastResort, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + { + if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && + glyphTypeface != null && + glyphTypeface.IsLastResort == isLastResort) { return true; } @@ -582,14 +600,14 @@ namespace Avalonia.Media.Fonts if (key.Stretch != FontStretch.Normal) { - if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) + if (TryFindStretchFallback(glyphTypefaces, key, isLastResort, out glyphTypeface)) { return true; } if (key.Weight != FontWeight.Normal) { - if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, out glyphTypeface)) + if (TryFindStretchFallback(glyphTypefaces, key with { Weight = FontWeight.Normal }, isLastResort, out glyphTypeface)) { return true; } @@ -598,12 +616,12 @@ namespace Avalonia.Media.Fonts key = key with { Stretch = FontStretch.Normal }; } - if (TryFindWeightFallback(glyphTypefaces, key, out glyphTypeface)) + if (TryFindWeightFallback(glyphTypefaces, key, isLastResort, out glyphTypeface)) { return true; } - if (TryFindStretchFallback(glyphTypefaces, key, out glyphTypeface)) + if (TryFindStretchFallback(glyphTypefaces, key, isLastResort, out glyphTypeface)) { return true; } @@ -611,7 +629,7 @@ namespace Avalonia.Media.Fonts //Take the first glyph typeface we can find. foreach (var typeface in glyphTypefaces.Values) { - if (typeface != null) + if (typeface != null && isLastResort == typeface.IsLastResort) { glyphTypeface = typeface; @@ -695,12 +713,14 @@ namespace Avalonia.Media.Fonts /// A dictionary mapping font collection keys to their corresponding glyph typefaces. Used as the source for /// searching fallback typefaces. /// The font collection key specifying the desired font stretch and other font attributes to match. + /// Whether to match last resort fonts. /// When this method returns, contains the found glyph typeface with a similar stretch if one exists; otherwise, /// null. /// true if a suitable fallback glyph typeface is found; otherwise, false. private static bool TryFindStretchFallback( IDictionary glyphTypefaces, FontCollectionKey key, + bool isLastResort, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) { glyphTypeface = null; @@ -711,7 +731,7 @@ namespace Avalonia.Media.Fonts { for (var i = 0; stretch + i < 9; i++) { - if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch + i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithStretch(stretch, out glyphTypeface)) { return true; } @@ -721,13 +741,18 @@ namespace Avalonia.Media.Fonts { for (var i = 0; stretch - i > 1; i++) { - if (glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)(stretch - i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithStretch(stretch, out glyphTypeface)) { return true; } } } + bool TryGetWithStretch(int effectiveStretch, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + => glyphTypefaces.TryGetValue(key with { Stretch = (FontStretch)effectiveStretch }, out glyphTypeface) && + glyphTypeface != null && + glyphTypeface.IsLastResort == isLastResort; + return false; } @@ -742,12 +767,14 @@ namespace Avalonia.Media.Fonts /// for a suitable fallback. /// The font collection key specifying the desired font attributes, including weight, for which a fallback glyph /// typeface is sought. + /// Whether to match last resort fonts. /// When this method returns, contains the matching glyph typeface if a suitable fallback is found; otherwise, /// null. /// true if a fallback glyph typeface matching the requested weight is found; otherwise, false. private static bool TryFindWeightFallback( IDictionary glyphTypefaces, FontCollectionKey key, + bool isLastResort, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) { glyphTypeface = null; @@ -759,7 +786,7 @@ namespace Avalonia.Media.Fonts //Look for available weights between the target and 500, in ascending order. for (var i = 0; weight + i <= 500; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -768,7 +795,7 @@ namespace Avalonia.Media.Fonts //If no match is found, look for available weights less than the target, in descending order. for (var i = 0; weight - i >= 100; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -777,7 +804,7 @@ namespace Avalonia.Media.Fonts //If no match is found, look for available weights greater than 500, in ascending order. for (var i = 0; weight + i <= 900; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -789,7 +816,7 @@ namespace Avalonia.Media.Fonts { for (var i = 0; weight - i >= 100; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -798,7 +825,7 @@ namespace Avalonia.Media.Fonts //If no match is found, look for available weights less than the target, in descending order. for (var i = 0; weight + i <= 900; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -810,7 +837,7 @@ namespace Avalonia.Media.Fonts { for (var i = 0; weight + i <= 900; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight + i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -819,7 +846,7 @@ namespace Avalonia.Media.Fonts //If no match is found, look for available weights less than the target, in descending order. for (var i = 0; weight - i >= 100; i += 50) { - if (glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)(weight - i) }, out glyphTypeface) && glyphTypeface != null) + if (TryGetWithWeight(weight, out glyphTypeface)) { return true; } @@ -827,6 +854,11 @@ namespace Avalonia.Media.Fonts } return false; + + bool TryGetWithWeight(int effectiveWeight, [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + => glyphTypefaces.TryGetValue(key with { Weight = (FontWeight)effectiveWeight }, out glyphTypeface) && + glyphTypeface != null && + glyphTypeface.IsLastResort == isLastResort; } void IDisposable.Dispose() diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs index 9a461afb0b..83db40ba62 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Text; namespace Avalonia.Media.Fonts.Tables.Cmap { @@ -16,9 +14,10 @@ namespace Avalonia.Media.Fonts.Tables.Cmap public readonly struct CharacterToGlyphMap #pragma warning restore CA1815 // Override equals not needed for readonly struct { - private readonly CmapFormat _format; private readonly CmapFormat4Table? _format4; - private readonly CmapFormat12Table? _format12; + private readonly CmapFormat12Or13Table? _format12Or13; + + internal CmapFormat Format { get; } /// /// Initializes a new instance of the CharacterToGlyphMap class using the specified Format 4 cmap table. @@ -27,21 +26,21 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] internal CharacterToGlyphMap(CmapFormat4Table table) { - _format = CmapFormat.Format4; + Format = CmapFormat.Format4; _format4 = table; - _format12 = null; + _format12Or13 = null; } /// /// Initializes a new instance of the CharacterToGlyphMap class using the specified Format 12 character-to-glyph /// mapping table. /// - /// The Format 12 cmap table that defines the mapping from Unicode code points to glyph indices. Cannot be null. + /// The Format 12 or 13 cmap table that defines the mapping from Unicode code points to glyph indices. Cannot be null. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal CharacterToGlyphMap(CmapFormat12Table table) + internal CharacterToGlyphMap(CmapFormat12Or13Table table) { - _format = CmapFormat.Format12; - _format12 = table; + Format = table.Format; + _format12Or13 = table; _format4 = null; } @@ -65,10 +64,10 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] public ushort GetGlyph(int codePoint) { - return _format switch + return Format switch { CmapFormat.Format4 => _format4!.GetGlyph(codePoint), - CmapFormat.Format12 => _format12!.GetGlyph(codePoint), + CmapFormat.Format12 or CmapFormat.Format13 => _format12Or13!.GetGlyph(codePoint), _ => 0 }; } @@ -81,10 +80,10 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool ContainsGlyph(int codePoint) { - return _format switch + return Format switch { CmapFormat.Format4 => _format4!.ContainsGlyph(codePoint), - CmapFormat.Format12 => _format12!.ContainsGlyph(codePoint), + CmapFormat.Format12 or CmapFormat.Format13 => _format12Or13!.ContainsGlyph(codePoint), _ => false }; } @@ -102,20 +101,20 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] public void GetGlyphs(ReadOnlySpan codePoints, Span glyphIds) { - switch (_format) + switch (Format) { case CmapFormat.Format4: _format4!.GetGlyphs(codePoints, glyphIds); return; case CmapFormat.Format12: - _format12!.GetGlyphs(codePoints, glyphIds); + case CmapFormat.Format13: + _format12Or13!.GetGlyphs(codePoints, glyphIds); return; default: glyphIds.Clear(); return; } } - /// /// Attempts to retrieve the glyph identifier corresponding to the specified Unicode code point. @@ -127,10 +126,10 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetGlyph(int codePoint, out ushort glyphId) { - switch (_format) + switch (Format) { case CmapFormat.Format4: return _format4!.TryGetGlyph(codePoint, out glyphId); - case CmapFormat.Format12: return _format12!.TryGetGlyph(codePoint, out glyphId); + case CmapFormat.Format12 or CmapFormat.Format13: return _format12Or13!.TryGetGlyph(codePoint, out glyphId); default: glyphId = 0; return false; } } @@ -142,7 +141,7 @@ namespace Avalonia.Media.Fonts.Tables.Cmap [MethodImpl(MethodImplOptions.AggressiveInlining)] public CodepointRangeEnumerator GetMappedRanges() { - return new CodepointRangeEnumerator(_format, _format4, _format12); + return new CodepointRangeEnumerator(Format, _format4, _format12Or13); } } } diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Table.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs similarity index 88% rename from src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Table.cs rename to src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs index b4440e7884..cc20e735d5 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Table.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapFormat12Or13Table.cs @@ -1,30 +1,31 @@ using System; using System.Buffers.Binary; -using System.Collections; -using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; namespace Avalonia.Media.Fonts.Tables.Cmap { - internal sealed class CmapFormat12Table + internal sealed class CmapFormat12Or13Table { private readonly ReadOnlyMemory _table; private readonly int _groupCount; private readonly ReadOnlyMemory _groups; + public CmapFormat Format { get; } + /// /// Gets the language code for the cmap subtable. /// For non-language-specific tables, this value is 0. /// public uint Language { get; } - public CmapFormat12Table(ReadOnlyMemory table) + public CmapFormat12Or13Table(ReadOnlyMemory table) { var reader = new BigEndianBinaryReader(table.Span); ushort format = reader.ReadUInt16(); - Debug.Assert(format == 12, "Format must be 12."); + Debug.Assert(format is 12 or 13, "Format must be 12 or 13."); + Format = (CmapFormat)format; ushort reserved = reader.ReadUInt16(); Debug.Assert(reserved == 0, "Reserved field must be 0."); @@ -101,7 +102,7 @@ namespace Avalonia.Media.Fonts.Tables.Cmap // Optimization: check if codepoint is in the same group as previous if (lastGroup >= 0 && codePoint >= lastStart && codePoint <= lastEnd) { - glyphIds[i] = (ushort)(lastStartGlyph + (codePoint - lastStart)); + glyphIds[i] = CalcEffectiveGlyph(codePoint, lastStart, lastStartGlyph); continue; } @@ -122,27 +123,13 @@ namespace Avalonia.Media.Fonts.Tables.Cmap lastEnd = ReadUInt32BE(groups, groupIndex, 4); lastStartGlyph = ReadUInt32BE(groups, groupIndex, 8); - glyphIds[i] = (ushort)(lastStartGlyph + (codePoint - lastStart)); + glyphIds[i] = CalcEffectiveGlyph(codePoint, lastStart, lastStartGlyph); } } public bool TryGetGlyph(int codePoint, out ushort glyphId) { - int groupIndex = FindGroupIndex(codePoint); - - if (groupIndex < 0) - { - glyphId = 0; - return false; - } - - var groups = _groups.Span; - - uint start = ReadUInt32BE(groups, groupIndex, 0); - uint startGlyph = ReadUInt32BE(groups, groupIndex, 8); - - glyphId = (ushort)(startGlyph + (codePoint - start)); - + glyphId = this[codePoint]; return glyphId != 0; } @@ -180,10 +167,21 @@ namespace Avalonia.Media.Fonts.Tables.Cmap uint start = ReadUInt32BE(groups, groupIndex, 0); uint startGlyph = ReadUInt32BE(groups, groupIndex, 8); + return CalcEffectiveGlyph(codePoint, start, startGlyph); + } + } - // Calculate glyph index - return (ushort)(startGlyph + (codePoint - start)); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ushort CalcEffectiveGlyph(int codePoint, uint start, uint startGlyph) + { + // Format 13, all codepoints in the group map to a single glyph + if (Format == CmapFormat.Format13) + { + return (ushort)startGlyph; } + + // Format 12, calculate glyph index + return (ushort)(startGlyph + (codePoint - start)); } // Optimized binary search that works directly with cached span diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs index f526658133..7774294e76 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CmapTable.cs @@ -49,22 +49,28 @@ namespace Avalonia.Media.Fonts.Tables.Cmap } // Try to find the best Format 12 subtable entry - if (TryFindFormat12Entry(entries, out var format12Entry)) + if (TryFindFormat12Or13Entry(entries, CmapFormat.Format12, out var format12Entry)) { // Prefer Format 12 if available - return new CharacterToGlyphMap(new CmapFormat12Table(format12Entry.GetSubtableMemory(table))); + return new CharacterToGlyphMap(new CmapFormat12Or13Table(format12Entry.GetSubtableMemory(table))); } - // Fallback to Format 4 + // Then Format 4 if (TryFindFormat4Entry(entries, out var format4Entry)) { return new CharacterToGlyphMap(new CmapFormat4Table(format4Entry.GetSubtableMemory(table))); } + // Fallback to Format 13, which is a "last resort" format mapping many codepoints to a single glyph + if (TryFindFormat12Or13Entry(entries, CmapFormat.Format13, out var format13Entry)) + { + return new CharacterToGlyphMap(new CmapFormat12Or13Table(format13Entry.GetSubtableMemory(table))); + } + throw new InvalidOperationException("No suitable cmap subtable found."); // Tries to find the best Format 12 subtable entry based on platform and encoding preferences - static bool TryFindFormat12Entry(CmapSubtableEntry[] entries, out CmapSubtableEntry result) + static bool TryFindFormat12Or13Entry(CmapSubtableEntry[] entries, CmapFormat expectedFormat, out CmapSubtableEntry result) { result = default; var foundPlatformScore = int.MaxValue; @@ -72,7 +78,7 @@ namespace Avalonia.Media.Fonts.Tables.Cmap foreach (var entry in entries) { - if (entry.Format != CmapFormat.Format12) + if (entry.Format != expectedFormat) { continue; } diff --git a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CodepointRangeEnumerator.cs b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CodepointRangeEnumerator.cs index b631c264d1..627ce1b6b6 100644 --- a/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CodepointRangeEnumerator.cs +++ b/src/Avalonia.Base/Media/Fonts/Tables/Cmap/CodepointRangeEnumerator.cs @@ -6,21 +6,21 @@ namespace Avalonia.Media.Fonts.Tables.Cmap /// Enumerates contiguous ranges of Unicode code points present in a character map (cmap) table. /// /// This enumerator is typically used to iterate over all code point ranges defined by a cmap - /// table in an OpenType or TrueType font. It supports both Format 4 and Format 12 cmap subtables. The enumerator is - /// a ref struct and must be used within the stack context; it cannot be stored on the heap or used across await or - /// yield boundaries. + /// table in an OpenType or TrueType font. It supports Format 4, Format 12, and Format 13 cmap subtables. + /// The enumerator is a ref struct and must be used within the stack context; it cannot be stored on the + /// heap or used across await or yield boundaries. public ref struct CodepointRangeEnumerator { private readonly CmapFormat _format; private readonly CmapFormat4Table? _f4; - private readonly CmapFormat12Table? _f12; + private readonly CmapFormat12Or13Table? _f12Or13; private int _index; - internal CodepointRangeEnumerator(CmapFormat format, CmapFormat4Table? f4, CmapFormat12Table? f12) + internal CodepointRangeEnumerator(CmapFormat format, CmapFormat4Table? f4, CmapFormat12Or13Table? f12Or13) { _format = format; _f4 = f4; - _f12 = f12; + _f12Or13 = f12Or13; _index = -1; } @@ -52,8 +52,9 @@ namespace Avalonia.Media.Fonts.Tables.Cmap return result; } case CmapFormat.Format12: + case CmapFormat.Format13: { - var result = _f12!.TryGetRange(_index, out var range); + var result = _f12Or13!.TryGetRange(_index, out var range); Current = range; diff --git a/src/Avalonia.Base/Media/GlyphTypeface.cs b/src/Avalonia.Base/Media/GlyphTypeface.cs index 3b3875b5de..cb554f3e22 100644 --- a/src/Avalonia.Base/Media/GlyphTypeface.cs +++ b/src/Avalonia.Base/Media/GlyphTypeface.cs @@ -114,6 +114,9 @@ namespace Avalonia.Media HeadTable.TryLoad(this, out var headTable); + IsLastResort = (headTable is not null && (headTable.Flags & HeadFlags.LastResortFont) != 0) || + _cmapTable.Format == CmapFormat.Format13; + var postTable = PostTable.Load(this); var isFixedPitch = postTable.IsFixedPitch; @@ -354,6 +357,11 @@ namespace Avalonia.Media } } + /// + /// Gets whether the font should be used as a last resort, if no other fonts matched. + /// + internal bool IsLastResort { get; } + /// /// Attempts to retrieve the horizontal advance width for the specified glyph. /// diff --git a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs index 7f629c8e69..905d782b48 100644 --- a/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/GlyphTypefaceTests.cs @@ -12,7 +12,8 @@ namespace Avalonia.Base.UnitTests.Media { public class GlyphTypefaceTests { - private static string s_InterFontUri = "resm:Avalonia.Base.UnitTests.Assets.Inter-Regular.ttf?assembly=Avalonia.Base.UnitTests"; + private static readonly string s_InterFontUri = "resm:Avalonia.Base.UnitTests.Assets.Inter-Regular.ttf?assembly=Avalonia.Base.UnitTests"; + private static readonly string s_blankFontUri = "resm:Avalonia.Base.UnitTests.Assets.AdobeBlank2VF.ttf?assembly=Avalonia.Base.UnitTests"; [Fact] public void Should_Load_Inter_Font() @@ -321,6 +322,26 @@ namespace Avalonia.Base.UnitTests.Media Assert.NotEqual(glyphA, glyphB); } + [Fact] + public void CharacterToGlyphMap_With_Format13_Should_Have_Same_Glyph_For_Different_Characters() + { + var assetLoader = new StandardAssetLoader(); + + using var stream = assetLoader.Open(new Uri(s_blankFontUri)); + + var typeface = new GlyphTypeface(new CustomPlatformTypeface(stream)); + + var map = typeface.CharacterToGlyphMap; + + Assert.True(map.ContainsGlyph('A')); + Assert.True(map.ContainsGlyph('B')); + + var glyphA = map['A']; + var glyphB = map['B']; + + Assert.Equal(glyphA, glyphB); + } + [Fact] public void FontMetrics_LineSpacing_Should_Be_Calculated_Correctly() { diff --git a/tests/Avalonia.RenderTests/Assets/AdobeBlank2VF.ttf b/tests/Avalonia.RenderTests/Assets/AdobeBlank2VF.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1ac3440ef129199db1027290be82be0beaa3ea5 GIT binary patch literal 2896 zcmbVOOK4nG82-+^HwN!z&b{|Q0ocpVLu4QtJFzzL_YXjQkkX;y*r|cR^7tZ9SK!zmNkk{n>h*AZndA6G z>~Pns?LDi2y2kOFv3NB8-JR<{QU5!~9SO02-1`#zshkqn@Of0J5w8`FJQc(Mbdz<4D z#~s;RX~|thn(_B>9Gsg^CGk8jxIV{$Tym*^AX;euDaU?3nakY#Jkdz~GEnn%VZK

-dGC_V_l5h)w}+``r+_QE#pUeu9{&h(C!IA}(V7RXGyo9C{o%c6BYa86S-W}a#a zUHG-ybgQb>5=RFICeROTZCMTlEZse(sM+@NEi)rfa1kPGz#c8QZ^YPZp0~x1{OsSqXJkhehMXCKtwU{pL`kMvS zw{cNTu}?d1Z{FDaLiKT1uJ3c6Rx6u*n=@|L`j=`&-B`b+RyYsPiZxbPd6G$-x4{Y> z92xAO5la@kh~RyTy($~>!#7qQK$~i_IEawywz!V+K8r(DxE|f=w3Y91K2aZ9+=y}Ct_pX&!aaJy#^FGpYRtuhY4OifEpGMG$7jej5;USDA(w{5~m(K<)nL4qD9@^79-(3-TwrB0tX-U5 zv&_n8jU=xUQH*Gs=sa03Xr{(uj+QAsYmt4LmX(r zyg=Q5&gXz0%Sd>wf*-OV3KP(OB<=l2pP@NSH3Yjemh>~jX$*e zi}BgdkyHFa+yD61S-!syYt$ftL`hE$Qrb!n{)He+0SctGm@%aP7pd4XBHHMur_f!;G9pFvZ5Xt9*OgtY;7Uu zCOJ)1+LVs*|K?_RowYg3$YgRilZQhd3XQ^{GLfl|(tI9}DJ9Hp5;G06X^&-JLRjGRBDDgN;l&ubpf OL25RgnSZ#2nEnF<^1DL- literal 0 HcmV?d00001 diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/TestFontNoCmap412.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/TestFontNoCmap412.ttf index 03a0fbe53792ab0f1b77c50e966b4f3071900959..161ce20c4d1da7b8344e75c3336167575fc13449 100644 GIT binary patch delta 519 zcmaDN`a^Vrm=dP|0|SGbdvK_eOPNn80|RFSkgwno<`~2v!eR>KS4^~!k39nvR0#tK zim`kG3Vs0claq533mhCBf*BZ?6c`v7h0@9ri%w42_mF{sD+DO-mY!3YR(AOo3j+hQ z4Uiv}4wQG6UkPM$%>nZDGEx&$6hB{kI&q6Jqshb<@{v;*7+Bp3@{3E(Nv<|zVBoj| zRApEORK|K=@EcGU*cD~DB^AssmkSjPC7=a`s!_gZxM;6EP+k9nUW_|$_VYsRP u&>ciSJ+k57OgdG55{6+2n delta 501 zcmXZXy-Pw-7zXg?di=P4rBMn^S~N5jQc;6LAEFISMM6WAXxfk@WtSSl#>S+mzn~!+ z6KaScLMVb78lnhFC~yq>pdqp6>RHbFd*1ild(Qdwym_|09qJ!|aAb07B&3b60@e_- ze=IsYfmUNbb0Rl5(GpaYe#tNIy%<*_SmQnf&XMn*EC`f9tW8jsk#s#a#i7 z+e|ObKm4kG3oV^p6J?QM)^DNmVEyo7bpP{Y))oAQ5M_4o*i(w=_5Jc-Z)Z*^<^*hv z4mkBLzUnK`(x?wLR{rUgrFz$J^#iC?>#m<6ZD7YBEodRlcfu%bqD|6f+FYyVSQJ>e tTI4{bRcR}2mDaCmleW`#X$S3))*s=NZlar{U9?NuO}nLm;{kDD`3G_tZpQ!s diff --git a/tests/Avalonia.Skia.UnitTests/Media/CustomFontCollectionTests.cs b/tests/Avalonia.Skia.UnitTests/Media/CustomFontCollectionTests.cs index 66afed7a3a..7e49eabe03 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/CustomFontCollectionTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/CustomFontCollectionTests.cs @@ -11,8 +11,8 @@ namespace Avalonia.Skia.UnitTests.Media { public class CustomFontCollectionTests { - private const string NotoMono = - "resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests"; + private const string AssetsNamespace = "Avalonia.Skia.UnitTests.Assets"; + private const string AssetFonts = $"resm:{AssetsNamespace}?assembly=Avalonia.Skia.UnitTests"; [Fact] public void Should_AddGlyphTypeface_By_Stream() @@ -27,23 +27,45 @@ namespace Avalonia.Skia.UnitTests.Media var assetLoader = AvaloniaLocator.Current.GetRequiredService(); - var assets = assetLoader.GetAssets(new Uri(NotoMono, UriKind.Absolute), null).ToArray(); - - Assert.NotEmpty(assets); - - var notoMonoLocation = assets.First(); - - using var notoMonoStream = assetLoader.Open(notoMonoLocation); - - Assert.NotNull(notoMonoStream); + var infos = new[] + { + new FontAssetInfo($"{AssetsNamespace}.AdobeBlank2VF.ttf", "Adobe Blank 2 VF R"), + new FontAssetInfo($"{AssetsNamespace}.Inter-Regular.ttf", "Inter"), + new FontAssetInfo($"{AssetsNamespace}.Manrope-Light.ttf", "Manrope Light"), + new FontAssetInfo($"{AssetsNamespace}.MiSans-Normal.ttf", "MiSans Normal"), + new FontAssetInfo($"{AssetsNamespace}.NotoMono-Regular.ttf", "Noto Mono"), + new FontAssetInfo($"{AssetsNamespace}.NotoSans-Italic.ttf", "Noto Sans"), + new FontAssetInfo($"{AssetsNamespace}.NotoSansArabic-Regular.ttf", "Noto Sans Arabic"), + new FontAssetInfo($"{AssetsNamespace}.NotoSansDeseret-Regular.ttf", "Noto Sans Deseret"), + new FontAssetInfo($"{AssetsNamespace}.NotoSansHebrew-Regular.ttf", "Noto Sans Hebrew"), + new FontAssetInfo($"{AssetsNamespace}.NotoSansMiao-Regular.ttf", "Noto Sans Miao"), + new FontAssetInfo($"{AssetsNamespace}.NotoSansTamil-Regular.ttf", "Noto Sans Tamil"), + new FontAssetInfo($"{AssetsNamespace}.SourceSerif4_36pt-Italic.ttf", "Source Serif 4 36pt"), + new FontAssetInfo($"{AssetsNamespace}.TwitterColorEmoji-SVGinOT.ttf", "Twitter Color Emoji") + }; + + var assets = assetLoader.GetAssets(new Uri(AssetFonts, UriKind.Absolute), null) + .OrderBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(infos.Length, assets.Length); + + for (var i = 0; i < infos.Length; ++i) + { + var info = infos[i]; + var asset = assets[i]; - Assert.True(fontCollection.TryAddGlyphTypeface(notoMonoStream, out var glyphTypeface)); + Assert.Equal(info.Path, asset.AbsolutePath); - Assert.Equal("Inter", glyphTypeface.FamilyName); + using var fontStream = assetLoader.Open(asset); + Assert.NotNull(fontStream); - Assert.True(fontManager.TryGetGlyphTypeface(new Typeface("fonts:custom#Inter"), out var secondGlyphTypeface)); + Assert.True(fontCollection.TryAddGlyphTypeface(fontStream, out var glyphTypeface)); + Assert.Equal(info.FamilyName, glyphTypeface.FamilyName); - Assert.Equal(glyphTypeface, secondGlyphTypeface); + Assert.True(fontManager.TryGetGlyphTypeface(new Typeface($"fonts:custom#{info.FamilyName}"), out var secondGlyphTypeface)); + Assert.Same(glyphTypeface, secondGlyphTypeface); + } } } @@ -60,7 +82,7 @@ namespace Avalonia.Skia.UnitTests.Media var assetLoader = AvaloniaLocator.Current.GetRequiredService(); - var assets = assetLoader.GetAssets(new Uri(NotoMono, UriKind.Absolute), null).Where(x => x.AbsolutePath.EndsWith(".ttf")).ToArray(); + var assets = assetLoader.GetAssets(new Uri(AssetFonts, UriKind.Absolute), null).Where(x => x.AbsolutePath.EndsWith(".ttf")).ToArray(); foreach (var asset in assets) { @@ -157,11 +179,10 @@ namespace Avalonia.Skia.UnitTests.Media var fontCollection = new CustomFontCollection(new Uri("fonts:custom", UriKind.Absolute)); fontManager.AddFontCollection(fontCollection); - // Use the NotoMono resource as FontSource - var notoMonoUri = new Uri(NotoMono, UriKind.Absolute); + var allFontsUri = new Uri(AssetFonts, UriKind.Absolute); // Add the font resource - Assert.True(fontCollection.TryAddFontSource(notoMonoUri)); + Assert.True(fontCollection.TryAddFontSource(allFontsUri)); // Get the loaded family names var families = fontCollection.ToArray(); @@ -184,5 +205,7 @@ namespace Avalonia.Skia.UnitTests.Media { public override Uri Key { get; } = key; } + + private record struct FontAssetInfo(string Path, string FamilyName); } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs index 76d4857e64..2c45c1a936 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerTests.cs @@ -437,6 +437,41 @@ namespace Avalonia.Skia.UnitTests.Media } } + [Fact] + public void Should_Use_Last_Resort_Font_Last_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 Sans"); + + const string characters = "א𪜶"; + + var codepoint1 = Codepoint.ReadAt(characters, 0, out _); + Assert.Equal(0x5D0, codepoint1); // א + + // Typeface should come from the font collection - falling back to Noto Sans Hebrew + Assert.True(FontManager.Current.TryMatchCharacter(codepoint1, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, fontFamily, null, out var typeface1)); + Assert.NotNull(typeface1.FontFamily.Key); + Assert.Equal("Noto Sans Hebrew", typeface1.GlyphTypeface.FamilyName); + + var codepoint2 = Codepoint.ReadAt(characters, 1, out _); + Assert.Equal(0x2A736, codepoint2); // 𪜶 + + // Typeface should come from the font collection - falling back to Adobe Blank 2 VF R as a last resort + Assert.True(FontManager.Current.TryMatchCharacter(codepoint2, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, fontFamily, null, out var typeface2)); + Assert.NotNull(typeface2.FontFamily.Key); + Assert.Equal("Adobe Blank 2 VF R", typeface2.GlyphTypeface.FamilyName); + } + } + } + [InlineData("Arial")] [InlineData("#Arial")] [Win32Theory("Windows specific font")]