diff --git a/src/Avalonia.Base/Media/FontManager.cs b/src/Avalonia.Base/Media/FontManager.cs
index 60200dea85..481a69eaf7 100644
--- a/src/Avalonia.Base/Media/FontManager.cs
+++ b/src/Avalonia.Base/Media/FontManager.cs
@@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Logging;
using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
@@ -262,6 +263,17 @@ namespace Avalonia.Media
///
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface)
+ => TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily, culture,
+ Script.Unknown, out typeface);
+
+ ///
+ /// Character-to-typeface match with an optional shaping-capability constraint: when
+ /// is a complex script, only fonts that can shape it are
+ /// considered. imposes no constraint and is identical to the
+ /// public overload.
+ ///
+ internal bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
+ FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, Script shapingScript, out Typeface typeface)
{
if (_fontFallbacks != null)
{
@@ -271,7 +283,9 @@ namespace Avalonia.Media
{
typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch);
- if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ if (TryGetGlyphTypeface(typeface, out var glyphTypeface) &&
+ glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) &&
+ (shapingScript == Script.Unknown || glyphTypeface.CanShapeScript(shapingScript)))
{
return true;
}
@@ -298,7 +312,7 @@ namespace Avalonia.Media
if (TryGetFontCollection(source, out var fontCollection) &&
// With composite fonts we need to first check if the font collection contains the family if not we skip it
fontCollection.TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out _) &&
- fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface))
+ TryMatchCharacterInCollection(fontCollection, codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, shapingScript, out typeface))
{
if (typeface.FontFamily.Name == DefaultFontFamily.Name && i + 1 < compositeKey.Keys.Count)
{
@@ -315,7 +329,7 @@ namespace Avalonia.Media
if (fontUri.IsFontCollection())
{
if (TryGetFontCollection(fontUri, out var fontCollection) &&
- fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily.Name, culture, out typeface))
+ TryMatchCharacterInCollection(fontCollection, codepoint, fontStyle, fontWeight, fontStretch, fontFamily.Name, culture, shapingScript, out typeface))
{
return true;
}
@@ -323,7 +337,23 @@ namespace Avalonia.Media
}
//Try to find a match with the system font collection
- return SystemFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily?.Name,
+ return TryMatchCharacterInCollection(SystemFonts, codepoint, fontStyle, fontWeight, fontStretch,
+ fontFamily?.Name, culture, shapingScript, out typeface);
+ }
+
+ // Routes through the shaping-aware overload when the collection is a FontCollectionBase (all
+ // built-in collections are); a foreign IFontCollection gets the unconstrained match.
+ private static bool TryMatchCharacterInCollection(IFontCollection fontCollection, int codepoint,
+ FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, string? familyName,
+ CultureInfo? culture, Script shapingScript, out Typeface typeface)
+ {
+ if (fontCollection is FontCollectionBase fontCollectionBase)
+ {
+ return fontCollectionBase.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch,
+ familyName, culture, shapingScript, out typeface);
+ }
+
+ return fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName,
culture, out typeface);
}
diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
index 649c47f730..778a326e72 100644
--- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
+++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
@@ -20,8 +20,10 @@ namespace Avalonia.Media.Fonts
// Make this internal for testing purposes
internal readonly ConcurrentDictionary> _glyphTypefaceCache = new();
- // Cache of resolved script/culture fallback family names. A null value is a *negative* cache
- // entry which prevents repeated platform-fallback calls for the same script bucket.
+ // Cache of resolved script/culture fallback family names. A non-null value is the preferred
+ // fallback family for that script bucket: a Tier B hint that is still re-checked for coverage
+ // per codepoint, and which does NOT by itself suppress a platform call. A null value is a
+ // *negative* entry that prevents repeated platform-fallback calls for the same script bucket.
private readonly ConcurrentDictionary _scriptFallbackCache = new();
private readonly object _fontFamiliesLock = new();
@@ -45,6 +47,16 @@ namespace Avalonia.Media.Fonts
public virtual bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
string? familyName, CultureInfo? culture, out Typeface match)
+ => TryMatchCharacter(codepoint, style, weight, stretch, familyName, culture, Script.Unknown, out match);
+
+ ///
+ /// Character-to-typeface match with an optional shaping-capability constraint. When
+ /// is a complex script, only candidates that can shape it
+ /// () are considered;
+ /// imposes no constraint and is identical to the public overload.
+ ///
+ internal bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
+ string? familyName, CultureInfo? culture, Script shapingScript, out Typeface match)
{
match = default;
@@ -57,7 +69,7 @@ namespace Avalonia.Media.Fonts
// --- Tier A: requested family, coverage-checked, culture-compatible ---
if (familyName != null &&
_glyphTypefaceCache.TryGetValue(familyName, out var requestedFamily) &&
- TryGetCoveringMatch(requestedFamily, key, codepoint, isLastResort: false, out var requestedGlyphTypeface) &&
+ TryGetCoveringMatchForFamily(requestedFamily, key, codepoint, culture, shapingScript, out var requestedGlyphTypeface) &&
IsCultureCompatible(requestedGlyphTypeface, culture, script))
{
match = BuildTypefaceWithSynthesis(requestedGlyphTypeface, key);
@@ -71,7 +83,7 @@ namespace Avalonia.Media.Fonts
cachedFamily != null &&
!string.Equals(cachedFamily, familyName, StringComparison.OrdinalIgnoreCase) &&
_glyphTypefaceCache.TryGetValue(cachedFamily, out var cachedTypefaces) &&
- TryGetCoveringMatch(cachedTypefaces, key, codepoint, isLastResort: false, out var cachedGlyphTypeface))
+ TryGetCoveringMatchForFamily(cachedTypefaces, key, codepoint, culture, shapingScript, out var cachedGlyphTypeface))
{
match = BuildTypefaceWithSynthesis(cachedGlyphTypeface, key);
return true;
@@ -79,8 +91,14 @@ namespace Avalonia.Media.Fonts
}
// --- Tier C: deterministic cache sweep (non last-resort), culture-scored ---
- if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, isLastResort: false, out var bestGt, out var bestFamilyName))
- {
+ if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, shapingScript, isLastResort: false, out var bestGt, out var bestFamilyName))
+ {
+ // The sweep returns the nearest cached key for the winning family. A codepoint the
+ // bucket family can't cover (e.g. the Simplified-only 华 when the bucket is a JP font)
+ // lands here, so the exact-key upgrade is needed here too: otherwise a Bold face cached
+ // for an earlier run is rendered for a Normal request.
+ bestGt = PreferExactKey(bestGt, key, codepoint, culture, shapingScript);
+
if (FontFallbackScriptHints.IsLocaleSensitive(refinedScript) || culture != null)
{
_scriptFallbackCache.TryAdd(scriptKey, bestFamilyName);
@@ -90,25 +108,41 @@ namespace Avalonia.Media.Fonts
return true;
}
- // --- Tier D: platform fallback (at most once per (script, culture)) ---
- var platformAlreadyAttempted = _scriptFallbackCache.ContainsKey(scriptKey);
-
- if (!platformAlreadyAttempted &&
- TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt))
+ // --- Tier D: platform fallback ---
+ // Only a *negative* cache entry (the platform had no font for this script bucket)
+ // suppresses a retry. A *positive* entry must not: it records a preferred family for the
+ // bucket, but that family may not cover THIS codepoint - e.g. 中 (U+4E2D) resolves to a CJK
+ // font that lacks the Simplified-only 华 (U+534E) - and the platform resolves per codepoint,
+ // so it may still place a codepoint the bucket's font cannot. Reaching Tier D already means
+ // no cached family covered this codepoint.
+ // Skipped for shaping-constrained queries: the platform match is not capability-checked,
+ // and the caller's unconstrained pass already covers the platform path (so the negative
+ // cache stays consistent with unconstrained lookups).
+ if (shapingScript == Script.Unknown)
{
- _scriptFallbackCache.TryAdd(scriptKey, platformGt.FamilyName);
- match = BuildTypefaceWithSynthesis(platformGt, key);
- return true;
- }
+ var hasCacheEntry = _scriptFallbackCache.TryGetValue(scriptKey, out var cachedFamily);
+ var platformAlreadyAttempted = hasCacheEntry && cachedFamily is null;
- if (!platformAlreadyAttempted)
- {
- // Remember the negative answer so we don't ask the platform again.
- _scriptFallbackCache.TryAdd(scriptKey, null);
+ if (!platformAlreadyAttempted &&
+ TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt))
+ {
+ // Keep any existing positive hint (TryAdd won't overwrite); registering the match
+ // lets later lookups for this codepoint be served by Tier C without the platform.
+ _scriptFallbackCache.TryAdd(scriptKey, platformGt.FamilyName);
+ match = BuildTypefaceWithSynthesis(platformGt, key);
+ return true;
+ }
+
+ // Record a negative only when nothing was cached for the bucket yet, so a positive hint
+ // for one codepoint isn't downgraded to a negative by another the platform can't place.
+ if (!hasCacheEntry)
+ {
+ _scriptFallbackCache.TryAdd(scriptKey, null);
+ }
}
// --- Tier E: last-resort cache sweep ---
- if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, isLastResort: true, out var lrGt, out _))
+ if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, shapingScript, isLastResort: true, out var lrGt, out _))
{
match = BuildTypefaceWithSynthesis(lrGt, key);
return true;
@@ -124,6 +158,7 @@ namespace Avalonia.Media.Fonts
CultureInfo? culture,
Script script,
Script refinedScript,
+ Script shapingScript,
bool isLastResort,
[NotNullWhen(true)] out GlyphTypeface? bestGlyphTypeface,
[NotNullWhen(true)] out string? bestFamilyName)
@@ -149,7 +184,7 @@ namespace Avalonia.Media.Fonts
continue;
}
- if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, out var candidate))
+ if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, shapingScript, out var candidate))
{
continue;
}
@@ -280,7 +315,7 @@ namespace Avalonia.Media.Fonts
///
/// Hook for platform-backed collections (e.g. ) to consult
/// the underlying font manager for a fallback typeface. Invoked at most once per
- /// (script-bucket, culture) pair from .
+ /// (script-bucket, culture) pair from .
///
protected virtual bool TryMatchCharacterFromPlatform(
int codepoint,
@@ -293,23 +328,82 @@ namespace Avalonia.Media.Fonts
return false;
}
+ ///
+ /// Resolves a covering face for a single family at the requested key. Takes the cheap cached
+ /// covering match first (an exact-key hit needs nothing more) and only escalates to the exact
+ /// key when that match differs in any axis, so a Bold (or Italic, or Condensed) face cached for
+ /// one run is not reused for a differently-keyed request of the same family.
+ ///
+ private bool TryGetCoveringMatchForFamily(
+ ConcurrentDictionary glyphTypefaces,
+ FontCollectionKey key,
+ int codepoint,
+ CultureInfo? culture,
+ Script shapingScript,
+ [NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
+ {
+ if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort: false, shapingScript, out glyphTypeface))
+ {
+ return false;
+ }
+
+ glyphTypeface = PreferExactKey(glyphTypeface, key, codepoint, culture, shapingScript);
+ return true;
+ }
+
+ ///
+ /// When differs from the requested in
+ /// any axis (style, weight or stretch), asks the platform for the exact-key face of the same
+ /// family - the only source of a key the cache lacks - via ,
+ /// and returns it when it is an exact, shapeable match; otherwise returns the input unchanged.
+ /// The platform is consulted only on a mismatch, and a collection without one keeps the
+ /// neighbouring match. This guards every key axis, not just weight.
+ ///
+ private GlyphTypeface PreferExactKey(
+ GlyphTypeface glyphTypeface,
+ FontCollectionKey key,
+ int codepoint,
+ CultureInfo? culture,
+ Script shapingScript)
+ {
+ // The platform's character match, biased by the family already resolved, yields that family
+ // at the requested key when it has that face (MatchCharacter covers the codepoint, so no
+ // extra coverage check is needed). Accept it only when it is the exact key and can shape.
+ if (glyphTypeface.ToFontCollectionKey() != key &&
+ TryMatchCharacterFromPlatform(codepoint, key, glyphTypeface.FamilyName, culture, out var exact) &&
+ exact.ToFontCollectionKey() == key &&
+ CanShape(exact, shapingScript))
+ {
+ return exact;
+ }
+
+ return glyphTypeface;
+ }
+
///
/// Picks a variant of the family that both is close to the requested key and actually maps
/// the requested codepoint. Falls back through the existing weight/stretch search but
/// filters every candidate through the font's character-to-glyph map.
///
+ // A candidate satisfies a shaping-capability constraint when it can shape the requested
+ // script; Script.Unknown means no constraint (the historical, unconstrained behaviour).
+ private static bool CanShape(GlyphTypeface glyphTypeface, Script shapingScript)
+ => shapingScript == Script.Unknown || glyphTypeface.CanShapeScript(shapingScript);
+
private static bool TryGetCoveringMatch(
ConcurrentDictionary glyphTypefaces,
FontCollectionKey key,
int codepoint,
bool isLastResort,
+ Script shapingScript,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
{
// Exact key first.
if (glyphTypefaces.TryGetValue(key, out glyphTypeface) &&
glyphTypeface != null &&
glyphTypeface.IsLastResort == isLastResort &&
- glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) &&
+ CanShape(glyphTypeface, shapingScript))
{
return true;
}
@@ -330,7 +424,8 @@ namespace Avalonia.Media.Fonts
continue;
}
- if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) ||
+ !CanShape(candidate, shapingScript))
{
continue;
}
diff --git a/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs b/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
index c1912f62e9..ed05886f35 100644
--- a/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
+++ b/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
@@ -5,7 +5,7 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.Fonts
{
///
- /// Script-aware hints used by to
+ /// Script-aware hints used by to
/// disambiguate locale-sensitive scripts (e.g. CJK), to provide deterministic probe
/// codepoints for scoring candidate fonts, and to decide whether a candidate font is
/// compatible with the caller's culture.
@@ -177,5 +177,73 @@ namespace Avalonia.Media.Fonts
return bit >= 0;
}
+
+ // OpenType GSUB/GPOS script tags a font must declare to shape each complex script. Indic
+ // and Myanmar expose a modern ("dev2") and a legacy ("deva") tag; either is accepted.
+ private static readonly OpenTypeTag Arab = OpenTypeTag.Parse("arab");
+ private static readonly OpenTypeTag Syrc = OpenTypeTag.Parse("syrc");
+ private static readonly OpenTypeTag Mong = OpenTypeTag.Parse("mong");
+ private static readonly OpenTypeTag Thaa = OpenTypeTag.Parse("thaa");
+ private static readonly OpenTypeTag Khmr = OpenTypeTag.Parse("khmr");
+ private static readonly OpenTypeTag Tibt = OpenTypeTag.Parse("tibt");
+ private static readonly OpenTypeTag Sinh = OpenTypeTag.Parse("sinh");
+ private static readonly OpenTypeTag Dev2 = OpenTypeTag.Parse("dev2");
+ private static readonly OpenTypeTag Deva = OpenTypeTag.Parse("deva");
+ private static readonly OpenTypeTag Bng2 = OpenTypeTag.Parse("bng2");
+ private static readonly OpenTypeTag Beng = OpenTypeTag.Parse("beng");
+ private static readonly OpenTypeTag Gur2 = OpenTypeTag.Parse("gur2");
+ private static readonly OpenTypeTag Guru = OpenTypeTag.Parse("guru");
+ private static readonly OpenTypeTag Gjr2 = OpenTypeTag.Parse("gjr2");
+ private static readonly OpenTypeTag Gujr = OpenTypeTag.Parse("gujr");
+ private static readonly OpenTypeTag Ory2 = OpenTypeTag.Parse("ory2");
+ private static readonly OpenTypeTag Orya = OpenTypeTag.Parse("orya");
+ private static readonly OpenTypeTag Tml2 = OpenTypeTag.Parse("tml2");
+ private static readonly OpenTypeTag Taml = OpenTypeTag.Parse("taml");
+ private static readonly OpenTypeTag Tel2 = OpenTypeTag.Parse("tel2");
+ private static readonly OpenTypeTag Telu = OpenTypeTag.Parse("telu");
+ private static readonly OpenTypeTag Knd2 = OpenTypeTag.Parse("knd2");
+ private static readonly OpenTypeTag Knda = OpenTypeTag.Parse("knda");
+ private static readonly OpenTypeTag Mlm2 = OpenTypeTag.Parse("mlm2");
+ private static readonly OpenTypeTag Mlym = OpenTypeTag.Parse("mlym");
+ private static readonly OpenTypeTag Mym2 = OpenTypeTag.Parse("mym2");
+ private static readonly OpenTypeTag Mymr = OpenTypeTag.Parse("mymr");
+
+ ///
+ /// For scripts that require OpenType complex shaping (cursive joining, reordering, conjunct
+ /// formation, dependent-vowel positioning, …), returns the GSUB/GPOS script tag(s) a font
+ /// must declare in order to shape them. A font that maps the codepoints through cmap but
+ /// declares none of these tags cannot shape the script and should be skipped during fallback.
+ ///
+ ///
+ /// true with one or two acceptable script tags (Indic and Myanmar expose both a modern
+ /// "dev2"-style tag and a legacy "deva"-style tag; for single-tag scripts
+ /// equals ). false for scripts
+ /// that render acceptably from cmap alone (Latin, CJK, Hangul, Hebrew, Thai, …), for which no
+ /// layout-table gate should apply.
+ ///
+ public static bool TryGetComplexShapingTags(Script script, out OpenTypeTag primary, out OpenTypeTag secondary)
+ {
+ switch (script)
+ {
+ case Script.Arabic: primary = secondary = Arab; return true;
+ case Script.Syriac: primary = secondary = Syrc; return true;
+ case Script.Mongolian: primary = secondary = Mong; return true;
+ case Script.Thaana: primary = secondary = Thaa; return true;
+ case Script.Khmer: primary = secondary = Khmr; return true;
+ case Script.Tibetan: primary = secondary = Tibt; return true;
+ case Script.Sinhala: primary = secondary = Sinh; return true;
+ case Script.Devanagari: primary = Dev2; secondary = Deva; return true;
+ case Script.Bengali: primary = Bng2; secondary = Beng; return true;
+ case Script.Gurmukhi: primary = Gur2; secondary = Guru; return true;
+ case Script.Gujarati: primary = Gjr2; secondary = Gujr; return true;
+ case Script.Oriya: primary = Ory2; secondary = Orya; return true;
+ case Script.Tamil: primary = Tml2; secondary = Taml; return true;
+ case Script.Telugu: primary = Tel2; secondary = Telu; return true;
+ case Script.Kannada: primary = Knd2; secondary = Knda; return true;
+ case Script.Malayalam: primary = Mlm2; secondary = Mlym; return true;
+ case Script.Myanmar: primary = Mym2; secondary = Mymr; return true;
+ default: primary = default; secondary = default; return false;
+ }
+ }
}
}
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs
new file mode 100644
index 0000000000..adae14c335
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Fonts.Tables
+{
+ ///
+ /// Reads the script tags declared in a font's OpenType Layout (GSUB/GPOS)
+ /// ScriptList. A declared script tag signals that the font carries shaping rules for that
+ /// script — the signal used to decide whether a font can actually shape a complex
+ /// script rather than merely map its codepoints through cmap.
+ ///
+ ///
+ internal static class ScriptListTable
+ {
+ private static readonly OpenTypeTag s_gsub = OpenTypeTag.Parse("GSUB");
+ private static readonly OpenTypeTag s_gpos = OpenTypeTag.Parse("GPOS");
+
+ ///
+ /// Adds the GSUB and GPOS script tags declared by to
+ /// .
+ ///
+ ///
+ /// false if a layout table is present but could not be parsed — the caller should
+ /// then treat shaping capability as unknown rather than unsupported (don't reject the font
+ /// on the strength of an empty set). An absent table is not a failure.
+ ///
+ public static bool TryReadScriptTags(GlyphTypeface glyphTypeface, HashSet scriptTags)
+ {
+ // Non-short-circuiting '&' so both tables are always attempted.
+ return TryReadScriptList(glyphTypeface, s_gsub, scriptTags)
+ & TryReadScriptList(glyphTypeface, s_gpos, scriptTags);
+ }
+
+ private static bool TryReadScriptList(GlyphTypeface glyphTypeface, OpenTypeTag tableTag,
+ HashSet scriptTags)
+ {
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(tableTag, out var table))
+ {
+ // An absent table simply contributes no scripts
+ return true;
+ }
+
+ try
+ {
+ var reader = new BigEndianBinaryReader(table.Span);
+
+ // GSUB/GPOS header: majorVersion, minorVersion, scriptListOffset, featureListOffset,
+ // lookupListOffset.
+ reader.ReadUInt16();
+ reader.ReadUInt16();
+ var scriptListOffset = reader.ReadOffset16();
+
+ // ScriptList: scriptCount, then scriptCount ScriptRecords of (Tag, Offset16).
+ reader.Seek(scriptListOffset);
+
+ var scriptCount = reader.ReadUInt16();
+
+ for (var i = 0; i < scriptCount; i++)
+ {
+ scriptTags.Add(new OpenTypeTag(reader.ReadUInt32()));
+ reader.ReadOffset16(); // scriptOffset — not needed
+ }
+
+ return true;
+ }
+ catch (Exception)
+ {
+ // Malformed layout table — capability unknown.
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/GlyphTypeface.cs b/src/Avalonia.Base/Media/GlyphTypeface.cs
index 22aaf1c1c3..9edee48991 100644
--- a/src/Avalonia.Base/Media/GlyphTypeface.cs
+++ b/src/Avalonia.Base/Media/GlyphTypeface.cs
@@ -44,6 +44,13 @@ namespace Avalonia.Media
private ITextShaperTypeface? _textShaperTypeface;
private UnicodeRange? _supportedUnicodeRange;
+ // Lazily-built set of OpenType script tags the font declares in GSUB/GPOS, used by
+ // CanShapeScript. Parsing copies the layout tables, so it is deferred until a complex script
+ // is actually queried (most text never triggers it). Published via the volatile field.
+ private volatile HashSet? _shapingScriptTags;
+ private bool _shapingScriptTagsUnknown;
+ private readonly object _shapingScriptTagsLock = new();
+
///
/// Initializes a new instance of the class with the specified platform typeface and
/// font simulations.
@@ -543,6 +550,59 @@ namespace Avalonia.Media
return false;
}
+ ///
+ /// Determines whether this font can shape the specified script, not merely map its
+ /// codepoints. Scripts that need OpenType complex shaping (e.g. Arabic joining, Indic
+ /// conjuncts) require the font to declare the script in its GSUB/GPOS tables; scripts that
+ /// render acceptably from cmap alone always return true. Used by the fallback itemizer
+ /// to avoid selecting a font that has the glyphs but cannot form them correctly.
+ ///
+ public bool CanShapeScript(Script script)
+ {
+ if (!FontFallbackScriptHints.TryGetComplexShapingTags(script, out var primary, out var secondary))
+ {
+ // Simple script: cmap coverage (checked by the caller) is sufficient.
+ return true;
+ }
+
+ var tags = EnsureShapingScriptTags();
+
+ // A present-but-unparseable GSUB/GPOS leaves capability unknown — don't reject on that
+ // basis; cmap remains the authority as it was before.
+ if (_shapingScriptTagsUnknown)
+ {
+ return true;
+ }
+
+ return tags.Contains(primary) || tags.Contains(secondary);
+ }
+
+ private HashSet EnsureShapingScriptTags()
+ {
+ var tags = _shapingScriptTags;
+
+ if (tags is not null)
+ {
+ return tags;
+ }
+
+ lock (_shapingScriptTagsLock)
+ {
+ if (_shapingScriptTags is not null)
+ {
+ return _shapingScriptTags;
+ }
+
+ var set = new HashSet();
+
+ // Set the "unknown" flag before publishing the set so a lock-free reader that sees the
+ // volatile set also sees the flag.
+ _shapingScriptTagsUnknown = !ScriptListTable.TryReadScriptTags(this, set);
+
+ return _shapingScriptTags = set;
+ }
+ }
+
private UnicodeRange BuildSupportedUnicodeRange()
{
var segments = new List();
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
index cc48ed47c6..b52b992746 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
@@ -1,4 +1,6 @@
using System;
+using Avalonia.Logging;
+using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting.Unicode;
using static Avalonia.Media.TextFormatting.FormattingObjectPool;
@@ -9,7 +11,11 @@ namespace Avalonia.Media.TextFormatting
///
public class TextCharacters : TextRun
{
- private static char ZeroWidthSpace = '\u200b';
+ // NUL characters render nothing but must keep their position in the run. WORD JOINER (U+2060)
+ // is a zero-width, default-ignorable, non-breaking filler; unlike ZERO WIDTH SPACE (U+200B)
+ // it introduces no line-break opportunity, matching NUL's lack of break semantics.
+ private const char WordJoiner = '\u2060';
+ private static readonly string s_wordJoinerRun = new string(WordJoiner, 8);
///
/// Constructs a run for text content from a string.
@@ -95,60 +101,141 @@ namespace Avalonia.Media.TextFormatting
//Detect null terminator
if (count > 0)
{
- return new UnshapedTextRun(new string(ZeroWidthSpace, count).AsMemory(), defaultProperties, biDiLevel);
+ // Reuse a cached run of WORD JOINERs for the common short case to avoid an allocation.
+ var nullReplacement = count <= s_wordJoinerRun.Length
+ ? s_wordJoinerRun.AsMemory(0, count)
+ : new string(WordJoiner, count).AsMemory();
+
+ return new UnshapedTextRun(nullReplacement, defaultProperties, biDiLevel);
}
- if (TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, out count))
+ // The first scalar's script drives both the locale-sensitivity check and the complex-script
+ // capability preference below.
+ var firstScript = Codepoint.ReadAt(textSpan, 0, out _).Script;
+
+ // The previous run's font is reused as a cheap anti-thrashing bias, but it bypasses the
+ // culture-aware fallback scorer. For locale-sensitive scripts (CJK Han unification) skip
+ // the reuse when the culture changed between runs so the culture-scored fallback runs
+ // instead - otherwise e.g. a zh run's font would pin ja text. Same-culture runs keep the
+ // reuse, so the common case pays only a culture comparison.
+ var allowPreviousTypeface = true;
+
+ if (previousGlyphTypeface is not null &&
+ !Equals(previousProperties!.CultureInfo, defaultProperties.CultureInfo) &&
+ FontFallbackScriptHints.IsLocaleSensitive(firstScript))
{
- return new UnshapedTextRun(text.Slice(0, count), defaultProperties.WithTypeface(defaultTypeface),
- biDiLevel);
+ allowPreviousTypeface = false;
}
- if (previousGlyphTypeface is not null)
+ // Capability preference (Strategy A): for a complex script, first try fonts that declare
+ // the script in GSUB/GPOS (so they can actually shape it), then fall back to cmap-only
+ // coverage. Simple scripts use a single cmap tier, so the common path is unchanged.
+ var capabilityTiers =
+ FontFallbackScriptHints.TryGetComplexShapingTags(firstScript, out _, out _) ? 2 : 1;
+
+ for (var capabilityTier = 0; capabilityTier < capabilityTiers; capabilityTier++)
{
- if (TryGetShapeableLength(textSpan, previousGlyphTypeface, defaultGlyphTypeface, out count))
+ // Tier 0 (only present when there are two tiers) requires the font to declare shaping
+ // support for the script; tier 1 accepts cmap coverage alone (the historical gate).
+ var requireShapingCapability = capabilityTiers == 2 && capabilityTier == 0;
+
+ // When this tier requires shaping capability, constrain the fallback search to fonts
+ // that can shape the script (Script.Unknown = the historical, unconstrained search).
+ var shapingConstraint = requireShapingCapability ? firstScript : Script.Unknown;
+
+ // Coverage tiers (full cluster, then base-only) run inside each capability tier. The
+ // fallback is resolved once per capability tier because the search constraint differs.
+ Typeface fallbackTypeface = default;
+ GlyphTypeface? fallbackGlyphTypeface = null;
+ var fallbackResolved = false;
+
+ // A primary that cannot shape this tier's script is not a valid "return target": pass
+ // null so the return-to-primary check doesn't hand clusters back to it, which would
+ // otherwise block a shaping-capable fallback that merely shares the primary's cmap.
+ var defaultCanShape = !requireShapingCapability || defaultGlyphTypeface.CanShapeScript(firstScript);
+ var primaryForReturn = defaultCanShape ? defaultGlyphTypeface : null;
+
+ for (var pass = 0; pass < 2; pass++)
{
- return new UnshapedTextRun(text.Slice(0, count),
- defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
- }
- }
+ var requireFullCluster = pass == 0;
- var codepoint = Codepoint.ReplacementCodepoint;
+ if (defaultCanShape &&
+ TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, requireFullCluster, out count))
+ {
+ // Primary font: the properties already carry this typeface, so reuse them
+ // directly. This avoids a needless copy and preserves a custom
+ // TextRunProperties subclass that WithTypeface would otherwise flatten.
+ return new UnshapedTextRun(text.Slice(0, count), defaultProperties, biDiLevel);
+ }
- var graphemeEnumerator = new GraphemeEnumerator(text.Slice(count).Span);
+ if (allowPreviousTypeface && previousGlyphTypeface is not null &&
+ (!requireShapingCapability || previousGlyphTypeface.CanShapeScript(firstScript)) &&
+ TryGetShapeableLength(textSpan, previousGlyphTypeface, primaryForReturn, requireFullCluster, out count))
+ {
+ return new UnshapedTextRun(text.Slice(0, count),
+ defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
+ }
- if (graphemeEnumerator.MoveNext(out var grapheme))
- {
- codepoint = grapheme.FirstCodepoint;
- }
+ // Resolve the fallback once, after the primary/previous probes fail. It is keyed on
+ // the first scalar the primary font cannot render - the base for an unsupported
+ // script, or the combining mark for an otherwise-supported cluster - so the search
+ // can find a font for the mark, not just the base. Reused by every pass.
+ if (!fallbackResolved)
+ {
+ fallbackResolved = true;
- var matchFound =
- fontManager.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight,
- defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo,
- out var fallbackTypeface);
-
- if (matchFound)
- {
- // Fallback found
- if(fontManager.TryGetGlyphTypeface(fallbackTypeface, out var fallbackGlyphTypeface))
- {
- if (TryGetShapeableLength(textSpan, fallbackGlyphTypeface, defaultGlyphTypeface, out count))
+ var fallbackCodepoint = GetFallbackCodepoint(textSpan, defaultGlyphTypeface);
+
+ if (fontManager.TryMatchCharacter(fallbackCodepoint, defaultTypeface.Style, defaultTypeface.Weight,
+ defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo,
+ shapingConstraint, out fallbackTypeface)
+ && !fontManager.TryGetGlyphTypeface(fallbackTypeface, out fallbackGlyphTypeface))
+ {
+ // The platform matched a fallback family but its glyph typeface could not
+ // be loaded; the cluster degrades to .notdef. Surface it for diagnosis.
+ Logger.TryGet(LogEventLevel.Warning, LogArea.Fonts)?.Log(null,
+ "Matched fallback typeface {FamilyName} for codepoint U+{Codepoint} but could not load its glyph typeface.",
+ fallbackTypeface.FontFamily.Name, ((uint)fallbackCodepoint).ToString("X4"));
+ }
+ }
+
+ if (fallbackGlyphTypeface is not null &&
+ TryGetShapeableLength(textSpan, fallbackGlyphTypeface, primaryForReturn, requireFullCluster, out count))
{
- return new UnshapedTextRun(text.Slice(0, count), defaultProperties.WithTypeface(fallbackTypeface),
- biDiLevel);
+ return new UnshapedTextRun(text.Slice(0, count),
+ defaultProperties.WithTypeface(fallbackTypeface), biDiLevel);
}
- }
+ }
}
- // no fallback found
+ // No font (not even a last-resort match) covers the first cluster. Coalesce the
+ // following clusters that likewise have no home into a single .notdef ("tofu") run,
+ // then hand control back so the next run can be selected normally. We must stop as
+ // soon as a cluster the primary font - or any fallback - can render is reached;
+ // otherwise a renderable cluster following an unmatchable one would be swallowed as
+ // tofu too (e.g. a private-use codepoint immediately followed by CJK text).
var enumerator = new GraphemeEnumerator(textSpan);
- //Move forward until we reach the next base character
- while (enumerator.MoveNext(out grapheme))
+ while (enumerator.MoveNext(out var grapheme))
{
- if (!grapheme.FirstCodepoint.IsWhiteSpace && defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(grapheme.FirstCodepoint, out _))
+ var firstCodepoint = grapheme.FirstCodepoint;
+
+ if (!firstCodepoint.IsWhiteSpace)
{
- break;
+ // Primary font regained coverage - return to it.
+ if (defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(firstCodepoint, out _))
+ {
+ break;
+ }
+
+ // A fallback exists for this cluster - stop so the next run can use it. The
+ // first cluster is skipped (count == 0): we already know it has no match.
+ if (count > 0 &&
+ fontManager.TryMatchCharacter(firstCodepoint, defaultTypeface.Style, defaultTypeface.Weight,
+ defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo, out _))
+ {
+ break;
+ }
}
count += grapheme.Length;
@@ -163,12 +250,18 @@ namespace Avalonia.Media.TextFormatting
/// The characters to shape.
/// The typeface that is used to find matching characters.
/// The default typeface.
+ ///
+ /// When true, a grapheme cluster only counts as supported when the typeface has a glyph
+ /// for every scalar it contains (base plus combining marks); when false, only the base
+ /// scalar is tested.
+ ///
/// The shapeable length.
///
internal static bool TryGetShapeableLength(
ReadOnlySpan text,
GlyphTypeface glyphTypeface,
GlyphTypeface? defaultGlyphTypeface,
+ bool requireFullCluster,
out int length)
{
length = 0;
@@ -192,17 +285,19 @@ namespace Avalonia.Media.TextFormatting
break;
}
+ var clusterText = text.Slice(currentGrapheme.Offset, currentGrapheme.Length);
+
if (!currentCodepoint.IsWhiteSpace
&& defaultGlyphTypeface != null
- && defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _))
+ && ClusterIsCovered(clusterText, currentCodepoint, defaultGlyphTypeface, requireFullCluster))
{
break;
}
- //Stop at the first missing glyph
+ //Stop at the first cluster this typeface can't render
if (!currentCodepoint.IsBreakChar &&
currentCodepoint.GeneralCategory != GeneralCategory.Control &&
- !glyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _))
+ !ClusterIsCovered(clusterText, currentCodepoint, glyphTypeface, requireFullCluster))
{
break;
}
@@ -228,5 +323,75 @@ namespace Avalonia.Media.TextFormatting
return length > 0;
}
+
+ ///
+ /// Determines whether can render the first grapheme cluster in
+ /// . For a single-scalar cluster, or when
+ /// is false, only the base scalar is tested.
+ /// Otherwise every scalar that needs a glyph (excluding break chars and control/format
+ /// codepoints) must be present, so a base+mark cluster is only covered by a font that has the
+ /// marks too.
+ ///
+ private static bool ClusterIsCovered(ReadOnlySpan clusterText, Codepoint firstCodepoint,
+ GlyphTypeface glyphTypeface, bool requireFullCluster)
+ {
+ var baseLength = firstCodepoint.Value > 0xFFFF ? 2 : 1;
+
+ if (!requireFullCluster || clusterText.Length <= baseLength)
+ {
+ return glyphTypeface.CharacterToGlyphMap.TryGetGlyph(firstCodepoint, out _);
+ }
+
+ var codepoints = new CodepointEnumerator(clusterText);
+
+ while (codepoints.MoveNext(out var codepoint))
+ {
+ if (codepoint.IsBreakChar || codepoint.GeneralCategory is GeneralCategory.Control or GeneralCategory.Format)
+ {
+ continue;
+ }
+
+ if (!glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns the first scalar of the run's first grapheme cluster that
+ /// cannot render - the base for an unsupported script,
+ /// or a combining mark for an otherwise-supported cluster. Keying the fallback search on this
+ /// lets it find a font for the mark, not just the base. Falls back to the cluster's first
+ /// scalar when every scalar is already covered.
+ ///
+ private static Codepoint GetFallbackCodepoint(ReadOnlySpan text, GlyphTypeface defaultGlyphTypeface)
+ {
+ var graphemeEnumerator = new GraphemeEnumerator(text);
+
+ if (!graphemeEnumerator.MoveNext(out var grapheme))
+ {
+ return Codepoint.ReplacementCodepoint;
+ }
+
+ var codepoints = new CodepointEnumerator(text.Slice(grapheme.Offset, grapheme.Length));
+
+ while (codepoints.MoveNext(out var codepoint))
+ {
+ if (codepoint.IsBreakChar || codepoint.GeneralCategory is GeneralCategory.Control or GeneralCategory.Format)
+ {
+ continue;
+ }
+
+ if (!defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ {
+ return codepoint;
+ }
+ }
+
+ return grapheme.FirstCodepoint;
+ }
}
}
diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansArabic-NoLayout.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansArabic-NoLayout.ttf
new file mode 100644
index 0000000000..40962448c9
Binary files /dev/null and b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansArabic-NoLayout.ttf differ
diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansJP-Subset.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansJP-Subset.ttf
new file mode 100644
index 0000000000..8d53a5bb35
Binary files /dev/null and b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansJP-Subset.ttf differ
diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansSC-Subset.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansSC-Subset.ttf
new file mode 100644
index 0000000000..26e9f744dc
Binary files /dev/null and b/tests/Avalonia.Skia.UnitTests/Fonts/NotoSansSC-Subset.ttf differ
diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs
index a630a63615..87d7ba876b 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using Avalonia.Media;
using Avalonia.Media.Fonts;
@@ -12,7 +13,7 @@ using Xunit;
namespace Avalonia.Skia.UnitTests.Media
{
///
- /// Verifies that resolves the same family
+ /// Verifies that resolves the same family
/// regardless of the order in which fonts were added to the collection, and is stable
/// across repeated invocations.
///
diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs
index c8f91393dd..ad10048298 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs
@@ -14,7 +14,7 @@ using Xunit;
namespace Avalonia.Skia.UnitTests.Media
{
///
- /// Exercises the tiered algorithm
+ /// Exercises the tiered algorithm
/// (Tier A → Tier E) using a small set of embedded test fonts and a stub subclass
/// that intercepts the platform-fallback hook.
///
@@ -220,6 +220,59 @@ namespace Avalonia.Skia.UnitTests.Media
null, null, out _));
}
+ // Regression for the "中华人民共和国 second codepoint is tofu" report. A positive (script,
+ // culture) bucket entry records a preferred family, but it must not suppress the Tier D
+ // platform lookup for another same-script codepoint that family cannot cover. Here 中 (U+4E2D)
+ // resolves to the Noto Sans SC subset, which lacks the Simplified-only 华 (U+534E); 华 must then
+ // reach the platform (which can place it) rather than be denied and rendered as tofu.
+ [Fact]
+ public void TierD_Positive_Script_Cache_Does_Not_Block_Platform_For_A_Codepoint_The_Bucket_Font_Lacks()
+ {
+ using var app = StartApp();
+
+ const int zhong = 0x4E2D; // 中 — covered by the Noto Sans SC subset
+ const int hua = 0x534E; // 华 — Simplified-only; absent from the SC subset
+
+ var collection = new RecordingFontCollection(new Uri("fonts:bucket-coverage", UriKind.Absolute));
+
+ // Latin primary (covers neither ideograph) plus the SC subset that covers 中 but not 华.
+ LoadFonts(collection, "NotoMono-Regular.ttf");
+ LoadFontsFromFontsNamespace(collection, "NotoSansSC-Subset.ttf");
+
+ // The SC subset is Thin (weight 100). Resolve at that weight so the Tier C exact-key upgrade
+ // (covered by the dedicated weight test) does not fire here, leaving this test to isolate the
+ // Tier D positive-bucket behaviour and its platform-call count.
+ const FontWeight weight = FontWeight.Thin;
+
+ Assert.True(collection.TryGetGlyphTypeface(
+ "Noto Sans SC", FontStyle.Normal, weight, FontStretch.Normal, out var scGt));
+ Assert.True(scGt.CharacterToGlyphMap.TryGetGlyph(zhong, out _));
+ Assert.False(scGt.CharacterToGlyphMap.TryGetGlyph(hua, out _));
+
+ // The platform can place 华 (MiSans covers it). It is deliberately NOT in the collection,
+ // so only Tier D can supply it.
+ var miSans = LoadStandaloneGlyphTypeface("Avalonia.Skia.UnitTests.Assets.MiSans-Normal.ttf");
+ Assert.True(miSans.CharacterToGlyphMap.TryGetGlyph(hua, out _));
+ collection.PlatformFallbackResult = miSans;
+
+ var zh = CultureInfo.GetCultureInfo("zh-CN");
+
+ // 中 resolves from the loaded cache (Tier C) and writes the (Han, zh-CN) bucket. The platform
+ // is not consulted for it.
+ Assert.True(collection.TryMatchCharacter(
+ zhong, FontStyle.Normal, weight, FontStretch.Normal, null, zh, out var zhongMatch));
+ Assert.Equal("Noto Sans SC", FamilyOf(zhongMatch));
+ Assert.Equal(0, collection.PlatformCallCount);
+
+ // 华 shares the bucket but the bucket font lacks it. Before the fix the positive entry made
+ // Tier D treat the bucket as "platform already attempted", so 华 returned no match. It must
+ // now reach the platform and resolve to a font that covers it.
+ Assert.True(collection.TryMatchCharacter(
+ hua, FontStyle.Normal, weight, FontStretch.Normal, null, zh, out var huaMatch));
+ Assert.Equal("MiSans Normal", FamilyOf(huaMatch));
+ Assert.Equal(1, collection.PlatformCallCount);
+ }
+
[Fact]
public void Match_Typeface_Carries_The_Requested_Style_Weight_And_Stretch()
{
@@ -238,6 +291,109 @@ namespace Avalonia.Skia.UnitTests.Media
Assert.Equal(FontStretch.Condensed, match.Stretch);
}
+ // Regression for the "Normal CJK fallback renders bold" report, generalized across the whole
+ // FontCollectionKey. The (script, culture) bucket is key-agnostic, so once one face of a fallback
+ // family is resolved, a later request that differs in ANY axis (weight via Bold, style via
+ // Oblique) must still resolve its own face, not reuse the cached neighbour. Stretch travels the
+ // same path. The leak is order-dependent, so both orders are checked.
+ [Theory]
+ [InlineData(FontSimulations.Bold, FontSimulations.None)] // weight: Bold then upright Normal (Sandbox order)
+ [InlineData(FontSimulations.None, FontSimulations.Bold)] // weight: reverse
+ [InlineData(FontSimulations.Oblique, FontSimulations.None)] // style: Italic then upright
+ [InlineData(FontSimulations.None, FontSimulations.Oblique)] // style: reverse
+ public void Fallback_Resolves_The_Requested_Typeface_Not_A_Cached_Neighbour(
+ FontSimulations first, FontSimulations second)
+ {
+ using var app = StartApp();
+
+ const int aleph = 0x05D0; // Hebrew — absent from any Latin primary, so it needs fallback.
+
+ // One family ("Noto Sans Hebrew") exposed by the platform as several faces (Regular plus a
+ // synthetic Bold and Oblique), modelling a .ttc whose styles/weights are separate faces. All
+ // cover aleph (same underlying font).
+ var faces = new Dictionary
+ {
+ [FontSimulations.None] = CreateGlyphTypeface(FontSimulations.None),
+ [FontSimulations.Bold] = CreateGlyphTypeface(FontSimulations.Bold),
+ [FontSimulations.Oblique] = CreateGlyphTypeface(FontSimulations.Oblique),
+ };
+
+ var collection = new KeyedFallbackCollection(new Uri("fonts:key", UriKind.Absolute), aleph);
+
+ foreach (var face in faces.Values)
+ {
+ collection.AddPlatformFace(face);
+ }
+
+ var firstFace = faces[first];
+ var secondFace = faces[second];
+ var culture = CultureInfo.GetCultureInfo("he-IL");
+
+ // First face: resolved via the platform (Tier D) and pins the bucket to the family.
+ Assert.True(collection.TryMatchCharacter(
+ aleph, firstFace.Style, firstFace.Weight, FontStretch.Normal, null, culture, out _));
+
+ // Second face: same family, same bucket, different key. It must come back as its own key.
+ Assert.True(collection.TryMatchCharacter(
+ aleph, secondFace.Style, secondFace.Weight, FontStretch.Normal, null, culture, out _));
+
+ Assert.True(collection.TryGetGlyphTypeface(
+ secondFace.FamilyName, secondFace.Style, secondFace.Weight, FontStretch.Normal, out var secondResult));
+ Assert.Equal(secondFace.ToFontCollectionKey(), secondResult.ToFontCollectionKey());
+
+ // The first face stays correct too.
+ Assert.True(collection.TryGetGlyphTypeface(
+ firstFace.FamilyName, firstFace.Style, firstFace.Weight, FontStretch.Normal, out var firstResult));
+ Assert.Equal(firstFace.ToFontCollectionKey(), firstResult.ToFontCollectionKey());
+ }
+
+ // Regression for "中华人民共和国: the first glyph is Normal but 华人民共和国 stay bold". A codepoint
+ // the bucket family cannot cover (the Simplified-only 华 when the bucket is a JP font) skips Tier B
+ // and lands in the Tier C sweep, where only a neighbouring-key face cached by an earlier run is
+ // available. The exact-key upgrade must run in Tier C too. Modelled with Tamil, which is not
+ // locale-sensitive, so resolving with no culture skips Tier B and drives the request into Tier C.
+ [Theory]
+ [InlineData(FontSimulations.Bold, FontSimulations.None)] // Sandbox order: Bold block above Normal one
+ [InlineData(FontSimulations.None, FontSimulations.Bold)] // reverse
+ public void TierC_Sweep_Resolves_The_Requested_Weight_Not_A_Cached_Neighbour(
+ FontSimulations first, FontSimulations second)
+ {
+ using var app = StartApp();
+
+ const int tamilKa = 0x0B95; // க — Tamil; not locale-sensitive, so culture=null skips Tier B.
+ const string tamil = "Avalonia.Skia.UnitTests.Assets.NotoSansTamil-Regular.ttf";
+
+ var faces = new Dictionary
+ {
+ [FontSimulations.None] = CreateGlyphTypeface(tamil, FontSimulations.None),
+ [FontSimulations.Bold] = CreateGlyphTypeface(tamil, FontSimulations.Bold),
+ };
+
+ Assert.Equal(FontWeight.Normal, faces[FontSimulations.None].Weight);
+ Assert.Equal(FontWeight.Bold, faces[FontSimulations.Bold].Weight);
+
+ var collection = new KeyedFallbackCollection(new Uri("fonts:tierc", UriKind.Absolute), tamilKa);
+
+ foreach (var face in faces.Values)
+ {
+ collection.AddPlatformFace(face);
+ }
+
+ var firstFace = faces[first];
+ var secondFace = faces[second];
+
+ // culture=null on a non-locale-sensitive script skips Tier B, so the second request lands in
+ // the Tier C sweep, where only the first (neighbouring-key) face is cached.
+ Assert.True(collection.TryMatchCharacter(
+ tamilKa, firstFace.Style, firstFace.Weight, FontStretch.Normal, null, null, out _));
+ Assert.True(collection.TryMatchCharacter(
+ tamilKa, secondFace.Style, secondFace.Weight, FontStretch.Normal, null, null, out _));
+
+ Assert.True(collection.TryGetGlyphTypeface(
+ secondFace.FamilyName, secondFace.Style, secondFace.Weight, FontStretch.Normal, out var secondResult));
+ Assert.Equal(secondFace.ToFontCollectionKey(), secondResult.ToFontCollectionKey());
+ }
+
private static IDisposable StartApp() =>
UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl()));
@@ -260,6 +416,53 @@ namespace Avalonia.Skia.UnitTests.Media
}
}
+ // The subset test fonts live in the Fonts resource namespace rather than Assets.
+ private static void LoadFontsFromFontsNamespace(FontCollectionBase collection, params string[] fileNames)
+ {
+ var loader = AvaloniaLocator.Current.GetRequiredService();
+
+ foreach (var fileName in fileNames)
+ {
+ var uri = new Uri($"resm:Avalonia.Skia.UnitTests.Fonts.{fileName}?assembly=Avalonia.Skia.UnitTests", UriKind.Absolute);
+ using var stream = loader.Open(uri);
+ Assert.True(collection.TryAddGlyphTypeface(stream, out _));
+ }
+ }
+
+ // Loads a font into a throwaway collection and returns its glyph typeface, so the platform
+ // stub can return it without the font being present in the collection under test.
+ private static GlyphTypeface LoadStandaloneGlyphTypeface(string resourceName)
+ {
+ var loader = AvaloniaLocator.Current.GetRequiredService();
+ var sink = new TestFontCollection(new Uri("fonts:sink", UriKind.Absolute));
+
+ using var stream = loader.Open(new Uri($"resm:{resourceName}?assembly=Avalonia.Skia.UnitTests", UriKind.Absolute));
+ Assert.True(sink.TryAddGlyphTypeface(stream, out var glyphTypeface));
+
+ return glyphTypeface!;
+ }
+
+ // Builds a Noto Sans Hebrew glyph typeface at the requested simulations. None is the Regular
+ // face; Bold and Oblique report weight Bold / style Italic respectively while keeping the same
+ // family name and cmap, so the faces model one family at several keys.
+ private static GlyphTypeface CreateGlyphTypeface(FontSimulations simulations)
+ => CreateGlyphTypeface($"{AssetsNamespace}.NotoSansHebrew-Regular.ttf", simulations);
+
+ private static GlyphTypeface CreateGlyphTypeface(string resourceName, FontSimulations simulations)
+ {
+ var loader = AvaloniaLocator.Current.GetRequiredService();
+ var fontManagerImpl = AvaloniaLocator.Current.GetRequiredService();
+
+ using var stream = loader.Open(new Uri($"resm:{resourceName}?assembly=Avalonia.Skia.UnitTests", UriKind.Absolute));
+
+ Assert.True(fontManagerImpl.TryCreateGlyphTypeface(stream, simulations, out var platformTypeface));
+
+ var glyphTypeface = GlyphTypeface.TryCreate(platformTypeface, simulations);
+ Assert.NotNull(glyphTypeface);
+
+ return glyphTypeface!;
+ }
+
private static string FamilyOf(Typeface typeface)
{
// Fallback typefaces are returned with FontFamily.Name == "#".
@@ -296,5 +499,45 @@ namespace Avalonia.Skia.UnitTests.Media
return glyphTypeface is not null;
}
}
+
+ // A platform-backed collection holding one fallback family at several keys (style/weight). Its
+ // single platform hook serves a face for an EXACT requested key only, modelling SystemFontCollection
+ // over a .ttc, so the key-honouring fallback path (including the exact-key upgrade, which reuses
+ // TryMatchCharacterFromPlatform) is exercised deterministically.
+ private sealed class KeyedFallbackCollection : FontCollectionBase
+ {
+ private readonly Dictionary _platformFaces = new();
+ private readonly int _coveredCodepoint;
+
+ public KeyedFallbackCollection(Uri key, int coveredCodepoint)
+ {
+ Key = key;
+ _coveredCodepoint = coveredCodepoint;
+ }
+
+ public override Uri Key { get; }
+
+ public void AddPlatformFace(GlyphTypeface face) => _platformFaces[face.ToFontCollectionKey()] = face;
+
+ protected override bool TryMatchCharacterFromPlatform(
+ int codepoint,
+ FontCollectionKey key,
+ string? familyName,
+ CultureInfo? culture,
+ [NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
+ {
+ glyphTypeface = null;
+
+ if (codepoint != _coveredCodepoint || !_platformFaces.TryGetValue(key, out var face))
+ {
+ return false;
+ }
+
+ // Register the matched face, as SystemFontCollection does, so later tiers can find it.
+ TryAddGlyphTypeface(face.FamilyName, key, face);
+ glyphTypeface = face;
+ return true;
+ }
+ }
}
}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/GlyphTypefaceShapingTests.cs b/tests/Avalonia.Skia.UnitTests/Media/GlyphTypefaceShapingTests.cs
new file mode 100644
index 0000000000..06b4d03684
--- /dev/null
+++ b/tests/Avalonia.Skia.UnitTests/Media/GlyphTypefaceShapingTests.cs
@@ -0,0 +1,91 @@
+#nullable enable
+
+using System;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting.Unicode;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Skia.UnitTests.Media
+{
+ public class GlyphTypefaceShapingTests
+ {
+ private const string MonoFont = "Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf";
+ private const string ArabicFont = "Avalonia.Skia.UnitTests.Assets.NotoSansArabic-Regular.ttf";
+
+ // A tiny Noto Sans Arabic subset with the GSUB/GPOS layout tables stripped: it keeps Arabic
+ // cmap glyphs but cannot shape Arabic. Renamed so it doesn't collide with the full font.
+ private const string ArabicNoLayoutFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansArabic-NoLayout.ttf";
+
+ // P0 — CanShapeScript gates complex scripts on GSUB/GPOS script coverage, not cmap. This is
+ // the primitive the F3 capability fallback (Strategy A) builds on.
+ [Fact]
+ public void CanShapeScript_Gates_Complex_Scripts_On_Layout_Coverage_Not_Cmap()
+ {
+ using (Start(MonoFont, ArabicFont, ArabicNoLayoutFont))
+ {
+ var fontManager = FontManager.Current;
+
+ Assert.True(fontManager.TryGetGlyphTypeface(
+ new Typeface("fonts:SystemFonts#Noto Mono"), out var mono));
+ Assert.True(fontManager.TryGetGlyphTypeface(
+ new Typeface("fonts:SystemFonts#Noto Sans Arabic"), out var arabic));
+ Assert.True(fontManager.TryGetGlyphTypeface(
+ new Typeface("fonts:SystemFonts#Noto Sans Arabic NoLayout"), out var arabicNoLayout));
+
+ // Simple scripts never require layout tables — always true, regardless of the font.
+ Assert.True(mono.CanShapeScript(Script.Latin));
+ Assert.True(arabic.CanShapeScript(Script.Latin));
+
+ // The real Arabic font declares the 'arab' GSUB script, so it can shape Arabic.
+ Assert.True(arabic.CanShapeScript(Script.Arabic));
+
+ // A Latin-only font has no Arabic layout coverage.
+ Assert.False(mono.CanShapeScript(Script.Arabic));
+
+ // The crux: the stripped font HAS Arabic cmap glyphs but no GSUB/GPOS, so it cannot
+ // shape Arabic even though TryGetGlyph succeeds. cmap coverage is not shaping capability.
+ Assert.True(arabicNoLayout.CharacterToGlyphMap.TryGetGlyph(0x0627, out _)); // ا is mapped
+ Assert.False(arabicNoLayout.CanShapeScript(Script.Arabic));
+
+ // A complex script the Arabic font does not declare is rejected too.
+ Assert.False(arabic.CanShapeScript(Script.Devanagari));
+ }
+ }
+
+ private static IDisposable Start(params string[] fontResourceNames)
+ {
+ var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
+ .With(renderInterface: new PlatformRenderInterface()));
+
+ var fontManagerImpl = new CustomFontManagerImpl();
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManagerImpl);
+
+ var fontManager = new FontManager(fontManagerImpl);
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManager);
+
+ fontManager.AddFontCollection(new CuratedSystemFontCollection(fontResourceNames));
+
+ return disposable;
+ }
+
+ private sealed class CuratedSystemFontCollection : FontCollectionBase
+ {
+ public CuratedSystemFontCollection(string[] fontResourceNames)
+ {
+ foreach (var name in fontResourceNames)
+ {
+ TryAddFontSource(new Uri($"resm:{name}?assembly=Avalonia.Skia.UnitTests"));
+ }
+ }
+
+ public override Uri Key => FontManager.SystemFontsKey;
+ }
+ }
+}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapingCapabilityFallbackTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapingCapabilityFallbackTests.cs
new file mode 100644
index 0000000000..310e820613
--- /dev/null
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapingCapabilityFallbackTests.cs
@@ -0,0 +1,110 @@
+#nullable enable
+
+using System;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Skia.UnitTests.Media.TextFormatting
+{
+ public class ShapingCapabilityFallbackTests
+ {
+ private const string MonoFont = "Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf";
+ private const string ArabicFont = "Avalonia.Skia.UnitTests.Assets.NotoSansArabic-Regular.ttf";
+
+ // Tiny Noto Sans Arabic subset with the layout tables stripped: it has the Arabic cmap glyphs
+ // but no GSUB/GPOS, so it cannot actually shape Arabic. Renamed so it doesn't collide with the
+ // full font.
+ private const string ArabicNoLayoutFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansArabic-NoLayout.ttf";
+
+ // For a complex script, a primary that has the cmap glyphs but can't shape it (no GSUB/GPOS) is
+ // upgraded to a shaping-capable font. This is unconditional — there is no longer a mode toggle.
+ [Fact]
+ public void CmapOnly_Complex_Script_Primary_Is_Upgraded_To_A_Shaping_Capable_Font()
+ {
+ // A shaping-capable Arabic font is present, so the cmap-only primary is replaced by it.
+ Assert.Equal("Noto Sans Arabic",
+ ResolveArabicRunFamily(MonoFont, ArabicFont, ArabicNoLayoutFont));
+ }
+
+ // When no shaping-capable font for the script exists, the cmap-only font is kept (the capability
+ // tier finds nothing, the cmap tier then accepts it) — we never reject more than before.
+ [Fact]
+ public void CmapOnly_Complex_Script_Primary_Is_Kept_When_No_Capable_Font_Exists()
+ {
+ Assert.Equal("Noto Sans Arabic NoLayout",
+ ResolveArabicRunFamily(MonoFont, ArabicNoLayoutFont));
+ }
+
+ private static string ResolveArabicRunFamily(params string[] fontResourceNames)
+ {
+ using (Start(fontResourceNames))
+ {
+ var fontManager = FontManager.Current;
+
+ // Primary run typeface: the cmap-only (no-layout) Arabic font.
+ var defaultProperties = new GenericTextRunProperties(
+ new Typeface("fonts:SystemFonts#Noto Sans Arabic NoLayout"));
+
+ var text = char.ConvertFromUtf32(0x0627).AsMemory(); // U+0627 ARABIC LETTER ALEF
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = null;
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ Assert.Single(results);
+ Assert.True(fontManager.TryGetGlyphTypeface(results[0].Properties!.Typeface, out var runGlyphTypeface));
+
+ return runGlyphTypeface.FamilyName;
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ private static IDisposable Start(string[] fontResourceNames)
+ {
+ var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
+ .With(renderInterface: new PlatformRenderInterface()));
+
+ var fontManagerImpl = new CustomFontManagerImpl();
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManagerImpl);
+
+ var fontManager = new FontManager(fontManagerImpl);
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManager);
+
+ fontManager.AddFontCollection(new CuratedSystemFontCollection(fontResourceNames));
+
+ return disposable;
+ }
+
+ private sealed class CuratedSystemFontCollection : FontCollectionBase
+ {
+ public CuratedSystemFontCollection(string[] fontResourceNames)
+ {
+ foreach (var name in fontResourceNames)
+ {
+ TryAddFontSource(new Uri($"resm:{name}?assembly=Avalonia.Skia.UnitTests"));
+ }
+ }
+
+ public override Uri Key => FontManager.SystemFontsKey;
+ }
+ }
+}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
new file mode 100644
index 0000000000..f58ee3b7de
--- /dev/null
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs
@@ -0,0 +1,308 @@
+#nullable enable
+
+using System;
+using System.Globalization;
+using Avalonia.Media;
+using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Skia.UnitTests.Media.TextFormatting
+{
+ public class TextCharactersTests
+ {
+ // Curated system fonts (see Start): "Noto Mono" is the primary; "DejaVu Sans" is a broad
+ // fallback that covers Hebrew and a wide range of combining marks. The broad-coverage faces
+ // bundled for other tests (AdobeBlank2VF, MiSans/NISC CJK) are excluded so that a CJK
+ // codepoint genuinely has no fallback.
+ private const string PrimaryFont = "Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf";
+ private const string FallbackFont = "Avalonia.Skia.UnitTests.Fonts.DejaVuSans.ttf";
+
+ // Tiny zh/ja regional subsets (a few glyphs each) of the Google Fonts Noto Sans SC / JP, with
+ // distinct OS/2 codepage bits and localized family names so the culture-aware fallback scorer
+ // can tell them apart. Both cover U+4E2D (中); only the JP subset covers U+3042 (あ).
+ private const string NotoSansScFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansSC-Subset.ttf";
+ private const string NotoSansJpFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansJP-Subset.ttf";
+
+ // U+4E2D 中 — a CJK ideograph covered by neither curated font, and with no platform fallback,
+ // so it has no match at all.
+ private const int NoMatchCodepoint = 0x4E2D;
+
+ // U+05D0 Hebrew aleph — covered by DejaVu Sans but not Noto Mono, so it resolves to a fallback.
+ private const int FallbackCodepoint = 0x05D0;
+
+ // F2 — a cluster that has no home (NoMatchCodepoint) immediately followed by one that does
+ // (FallbackCodepoint). This used to make the .notdef recovery loop swallow the renderable
+ // cluster into the tofu run.
+ [Fact]
+ public void GetShapeableCharacters_Does_Not_Swallow_Fallbackable_Cluster_After_Unmatchable_One()
+ {
+ using (Start(PrimaryFont, FallbackFont))
+ {
+ var fontManager = FontManager.Current;
+
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+ var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
+ var defaultFontFamily = defaultProperties.Typeface.FontFamily;
+
+ // Preconditions: the primary covers neither codepoint, the first has no fallback, the
+ // second does.
+ Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(NoMatchCodepoint, out _));
+ Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(FallbackCodepoint, out _));
+
+ Assert.False(fontManager.TryMatchCharacter(NoMatchCodepoint, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultFontFamily, null, out _));
+ Assert.True(fontManager.TryMatchCharacter(FallbackCodepoint, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultFontFamily, null, out _));
+
+ var text = string.Concat(
+ char.ConvertFromUtf32(NoMatchCodepoint),
+ char.ConvertFromUtf32(FallbackCodepoint)).AsMemory();
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = null;
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ // Before the fix this was a SINGLE coalesced .notdef run spanning both codepoints
+ // with the primary typeface — the Hebrew cluster was rendered as tofu even though a
+ // fallback exists. The recovery loop now stops at the fallbackable cluster.
+ Assert.Equal(2, results.Count);
+
+ // First run: the genuinely unmatchable cluster, left with the primary (tofu) typeface.
+ Assert.Equal(1, results[0].Length);
+ Assert.Equal(defaultProperties.Typeface, results[0].Properties!.Typeface);
+
+ // Second run: the Hebrew cluster, handed to a fallback that actually covers it.
+ Assert.Equal(1, results[1].Length);
+ Assert.NotEqual(defaultProperties.Typeface, results[1].Properties!.Typeface);
+ Assert.True(results[1].Properties!.CachedGlyphTypeface.CharacterToGlyphMap
+ .TryGetGlyph(FallbackCodepoint, out _));
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // F1 — a base+combining-mark cluster where the primary font has the base but not the mark, and
+ // a fallback covers the whole cluster. The whole cluster must be handed to that fallback rather
+ // than left on the primary (which would drop the mark).
+ [Fact]
+ public void GetShapeableCharacters_Prefers_A_Fallback_That_Covers_The_Whole_Cluster_Including_Marks()
+ {
+ using (Start(PrimaryFont, FallbackFont))
+ {
+ var fontManager = FontManager.Current;
+
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+ var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
+ var defaultFontFamily = defaultProperties.Typeface.FontFamily;
+
+ const int baseCodepoint = 'a';
+ Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(baseCodepoint, out _));
+
+ // Probe for a combining mark the primary lacks but a fallback covers together with the
+ // base. Probing keeps the test robust to the exact coverage of the embedded fonts.
+ var mark = 0;
+
+ foreach (var candidate in CombiningMarkCandidates)
+ {
+ if (defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(candidate, out _))
+ {
+ continue; // primary already covers it - not a useful probe
+ }
+
+ if (fontManager.TryMatchCharacter(candidate, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultFontFamily, null, out var markTypeface)
+ && fontManager.TryGetGlyphTypeface(markTypeface, out var markGlyphTypeface)
+ && markGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(baseCodepoint, out _))
+ {
+ mark = candidate;
+ break;
+ }
+ }
+
+ Assert.True(mark != 0,
+ "No combining mark found that the primary font lacks but a fallback covers together with the base.");
+
+ var text = ("a" + char.ConvertFromUtf32(mark)).AsMemory();
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = null;
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ // The base+mark cluster stays whole, on a font that covers the mark. Before the fix
+ // it was left on the primary (which has the base but not the mark), dropping the mark.
+ Assert.NotEmpty(results);
+
+ var firstRun = results[0];
+
+ Assert.Equal(text.Length, firstRun.Length);
+ Assert.NotEqual(defaultProperties.Typeface, firstRun.Properties!.Typeface);
+ Assert.True(firstRun.Properties!.CachedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(mark, out _),
+ "The cluster's run uses a font that does not cover the combining mark.");
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // F5 — NUL characters are replaced with non-breaking WORD JOINER (U+2060), not ZERO WIDTH
+ // SPACE (U+200B), which would introduce a line-break opportunity NUL never had.
+ [Fact]
+ public void GetShapeableCharacters_Replaces_Null_Characters_With_Non_Breaking_Word_Joiners()
+ {
+ using (Start(PrimaryFont, FallbackFont))
+ {
+ var fontManager = FontManager.Current;
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default);
+
+ var text = "\0\0".AsMemory();
+
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ TextRunProperties? previousProperties = null;
+
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ Assert.Single(results);
+ Assert.Equal(text.Length, results[0].Length);
+
+ foreach (var c in results[0].Text.Span)
+ {
+ Assert.Equal((char)0x2060, c);
+ }
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // F4 — the previous run's font is reused as an anti-thrashing bias, but for a locale-sensitive
+ // script (CJK Han unification) it must not be pinned across a culture change. A zh run's
+ // Simplified-Chinese font must not carry into a following ja run; the ja run resolves to the
+ // culture-appropriate Japanese font instead.
+ [Fact]
+ public void GetShapeableCharacters_Does_Not_Pin_Previous_Region_Font_Across_A_Culture_Change()
+ {
+ using (Start(PrimaryFont, NotoSansScFont, NotoSansJpFont))
+ {
+ var fontManager = FontManager.Current;
+ var ja = CultureInfo.GetCultureInfo("ja-JP");
+ var zh = CultureInfo.GetCultureInfo("zh-CN");
+
+ // Previous run: the Simplified-Chinese font, resolved for a zh culture.
+ var scTypeface = new Typeface(new FontFamily("fonts:SystemFonts#Noto Sans SC"));
+ Assert.True(fontManager.TryGetGlyphTypeface(scTypeface, out var scGlyphTypeface));
+
+ // Current run: a Latin primary that lacks the ideograph, under a ja culture.
+ var defaultProperties = new GenericTextRunProperties(Typeface.Default, cultureInfo: ja);
+
+ const int han = 0x4E2D; // 中 (a Han codepoint both regional fonts cover)
+
+ // Preconditions: primary lacks 中; the zh font covers it; and the culture-aware fallback
+ // for ja prefers the JP font over the SC font (distinct OS/2 codepage + localized names).
+ Assert.False(defaultProperties.CachedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(han, out _));
+ Assert.True(scGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(han, out _));
+ Assert.True(fontManager.TryMatchCharacter(han, FontStyle.Normal, FontWeight.Normal,
+ FontStretch.Normal, defaultProperties.Typeface.FontFamily, ja, out var jaMatch));
+ Assert.True(fontManager.TryGetGlyphTypeface(jaMatch, out var jaMatchGlyphTypeface));
+ Assert.Equal("Noto Sans JP", jaMatchGlyphTypeface.FamilyName);
+
+ var text = char.ConvertFromUtf32(han).AsMemory();
+ var textCharacters = new TextCharacters(text, defaultProperties);
+
+ TextRunProperties? previousProperties = new GenericTextRunProperties(scTypeface, cultureInfo: zh);
+
+ var results = FormattingObjectPool.Instance.TextRunLists.Rent();
+
+ try
+ {
+ textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
+
+ Assert.Single(results);
+ Assert.True(fontManager.TryGetGlyphTypeface(results[0].Properties!.Typeface, out var runGlyphTypeface));
+
+ // With the fix, the zh→ja culture change on a locale-sensitive script skips reuse of
+ // the previous (SC) font, so the run resolves to the ja-appropriate JP font. Before
+ // the fix the SC font was pinned and this was "Noto Sans SC".
+ Assert.Equal("Noto Sans JP", runGlyphTypeface.FamilyName);
+ }
+ finally
+ {
+ FormattingObjectPool.RentedList? toReturn = results;
+ FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
+ }
+ }
+ }
+
+ // A spread of combining marks (all grapheme-cluster Extend) likely present in a broad fallback
+ // font but absent from a minimal monospace primary. The F1 test picks the first workable one.
+ private static readonly int[] CombiningMarkCandidates =
+ {
+ 0x0316, 0x0317, 0x031C, 0x0323, 0x032E, 0x0333, 0x0359, 0x035C, 0x0360, 0x0361, 0x0362,
+ 0x0363, 0x036F, 0x0488, 0x0489, 0x1DC0, 0x1DC1, 0x20DD, 0x20E0,
+ };
+
+ private static IDisposable Start(params string[] fontResourceNames)
+ {
+ var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface
+ .With(renderInterface: new PlatformRenderInterface()));
+
+ var fontManagerImpl = new CustomFontManagerImpl();
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManagerImpl);
+
+ var fontManager = new FontManager(fontManagerImpl);
+
+ AvaloniaLocator.CurrentMutable
+ .Bind().ToConstant(fontManager);
+
+ // Register a curated system collection holding only the fonts each test needs. This excludes
+ // the broad-coverage fonts bundled for other tests, so coverage is exactly the requested set.
+ fontManager.AddFontCollection(new CuratedSystemFontCollection(fontResourceNames));
+
+ return disposable;
+ }
+
+ private sealed class CuratedSystemFontCollection : FontCollectionBase
+ {
+ public CuratedSystemFontCollection(string[] fontResourceNames)
+ {
+ foreach (var name in fontResourceNames)
+ {
+ TryAddFontSource(new Uri($"resm:{name}?assembly=Avalonia.Skia.UnitTests"));
+ }
+ }
+
+ public override Uri Key => FontManager.SystemFontsKey;
+ }
+ }
+}