Browse Source

[Text] Rework text fallback itemization (#21631)

* Add GlyphTypeface.CanShapeScript(Script), backed by a lazy read of the GSUB/GPOS ScriptList via the new ScriptListTable parser.
Add FontFallbackScriptHints.TryGetComplexShapingTags, which maps 17 complex scripts to the OpenType script tag(s) a font must declare to shape them.
Thread a Script shapingScript constraint through FontManager.TryMatchCharacter and FontCollectionBase.TryMatchCharacter as internal overloads, leaving the public signatures untouched.
Rework TextCharacters.CreateShapeableRun into a capability preference: a complex script tries shaping-capable fonts first (tier 0), then cmap-only coverage (tier 1); simple scripts keep the single historical tier.
Replace the NUL placeholder U+200B (ZERO WIDTH SPACE) with U+2060 (WORD JOINER) so the substitution introduces no line-break opportunity.
Suppress previous-run font reuse across a culture change for locale-sensitive scripts, so a zh run's font no longer pins following ja text.
Add 3 subset test fonts and 3 new test files (7 new tests) and fix 2 stale doc-comment crefs.

* Fix comments

* Fix font fallback reusing the wrong cached face from the script bucket

FontCollectionBase.TryMatchCharacter caches a resolved fallback family per
(script, culture) bucket. Two bugs let that bucket hand back a face that does
not match the request:

- A positive bucket entry suppressed the Tier D platform lookup for any later
  codepoint, even one the bucket family cannot cover. A CJK run left the
  Simplified-only U+534E unmatched (tofu) because the bucket resolved to a
  Japanese font that lacks it, while another installed font could place it.
  Tier D now treats only a negative (null) entry as "platform already
  attempted"; a positive entry no longer blocks the platform, since that
  family may not cover this codepoint.

- A cached covering match of a neighbouring FontCollectionKey was reused for a
  different requested key. After a Bold run cached a face, a following Normal
  run reused it and rendered bold (and the symmetric style and stretch cases).
  Tier A, B and C now keep the exact key: when the cached covering match is a
  neighbouring key, the exact-key face of the same family is resolved from the
  platform via TryMatchCharacterFromPlatform, falling back to the neighbour
  only when the platform has no exact face. Collections without a platform are
  unchanged.

Add regression tests for both: a bucket whose font lacks a later same-script
codepoint, and weight and style leaks resolved through Tier B (script cache)
and Tier C (the sweep, exercised via a non-locale-sensitive script with no
culture).
pull/21693/head
Benedikt Stebner 3 months ago
committed by GitHub
parent
commit
883a2477a5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      src/Avalonia.Base/Media/FontManager.cs
  2. 143
      src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
  3. 70
      src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
  4. 73
      src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs
  5. 60
      src/Avalonia.Base/Media/GlyphTypeface.cs
  6. 243
      src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
  7. BIN
      tests/Avalonia.Skia.UnitTests/Fonts/NotoSansArabic-NoLayout.ttf
  8. BIN
      tests/Avalonia.Skia.UnitTests/Fonts/NotoSansJP-Subset.ttf
  9. BIN
      tests/Avalonia.Skia.UnitTests/Fonts/NotoSansSC-Subset.ttf
  10. 3
      tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs
  11. 245
      tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs
  12. 91
      tests/Avalonia.Skia.UnitTests/Media/GlyphTypefaceShapingTests.cs
  13. 110
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/ShapingCapabilityFallbackTests.cs
  14. 308
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCharactersTests.cs

38
src/Avalonia.Base/Media/FontManager.cs

@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using Avalonia.Logging; using Avalonia.Logging;
using Avalonia.Media.Fonts; using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Utilities; using Avalonia.Utilities;
@ -262,6 +263,17 @@ namespace Avalonia.Media
/// </returns> /// </returns>
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface) FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface)
=> TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily, culture,
Script.Unknown, out typeface);
/// <summary>
/// Character-to-typeface match with an optional shaping-capability constraint: when
/// <paramref name="shapingScript"/> is a complex script, only fonts that can shape it are
/// considered. <see cref="Script.Unknown"/> imposes no constraint and is identical to the
/// public overload.
/// </summary>
internal bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, Script shapingScript, out Typeface typeface)
{ {
if (_fontFallbacks != null) if (_fontFallbacks != null)
{ {
@ -271,7 +283,9 @@ namespace Avalonia.Media
{ {
typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch); 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; return true;
} }
@ -298,7 +312,7 @@ namespace Avalonia.Media
if (TryGetFontCollection(source, out var fontCollection) && 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 // 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.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) if (typeface.FontFamily.Name == DefaultFontFamily.Name && i + 1 < compositeKey.Keys.Count)
{ {
@ -315,7 +329,7 @@ namespace Avalonia.Media
if (fontUri.IsFontCollection()) if (fontUri.IsFontCollection())
{ {
if (TryGetFontCollection(fontUri, out var fontCollection) && 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; return true;
} }
@ -323,7 +337,23 @@ namespace Avalonia.Media
} }
//Try to find a match with the system font collection //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); culture, out typeface);
} }

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

@ -20,8 +20,10 @@ namespace Avalonia.Media.Fonts
// Make this internal for testing purposes // Make this internal for testing purposes
internal readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, GlyphTypeface?>> _glyphTypefaceCache = new(); internal readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, GlyphTypeface?>> _glyphTypefaceCache = new();
// Cache of resolved script/culture fallback family names. A null value is a *negative* cache // Cache of resolved script/culture fallback family names. A non-null value is the preferred
// entry which prevents repeated platform-fallback calls for the same script bucket. // 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<ScriptFallbackKey, string?> _scriptFallbackCache = new(); private readonly ConcurrentDictionary<ScriptFallbackKey, string?> _scriptFallbackCache = new();
private readonly object _fontFamiliesLock = 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, public virtual bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
string? familyName, CultureInfo? culture, out Typeface match) string? familyName, CultureInfo? culture, out Typeface match)
=> TryMatchCharacter(codepoint, style, weight, stretch, familyName, culture, Script.Unknown, out match);
/// <summary>
/// Character-to-typeface match with an optional shaping-capability constraint. When
/// <paramref name="shapingScript"/> is a complex script, only candidates that can shape it
/// (<see cref="GlyphTypeface.CanShapeScript"/>) are considered; <see cref="Script.Unknown"/>
/// imposes no constraint and is identical to the public overload.
/// </summary>
internal bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
string? familyName, CultureInfo? culture, Script shapingScript, out Typeface match)
{ {
match = default; match = default;
@ -57,7 +69,7 @@ namespace Avalonia.Media.Fonts
// --- Tier A: requested family, coverage-checked, culture-compatible --- // --- Tier A: requested family, coverage-checked, culture-compatible ---
if (familyName != null && if (familyName != null &&
_glyphTypefaceCache.TryGetValue(familyName, out var requestedFamily) && _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)) IsCultureCompatible(requestedGlyphTypeface, culture, script))
{ {
match = BuildTypefaceWithSynthesis(requestedGlyphTypeface, key); match = BuildTypefaceWithSynthesis(requestedGlyphTypeface, key);
@ -71,7 +83,7 @@ namespace Avalonia.Media.Fonts
cachedFamily != null && cachedFamily != null &&
!string.Equals(cachedFamily, familyName, StringComparison.OrdinalIgnoreCase) && !string.Equals(cachedFamily, familyName, StringComparison.OrdinalIgnoreCase) &&
_glyphTypefaceCache.TryGetValue(cachedFamily, out var cachedTypefaces) && _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); match = BuildTypefaceWithSynthesis(cachedGlyphTypeface, key);
return true; return true;
@ -79,8 +91,14 @@ namespace Avalonia.Media.Fonts
} }
// --- Tier C: deterministic cache sweep (non last-resort), culture-scored --- // --- 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) if (FontFallbackScriptHints.IsLocaleSensitive(refinedScript) || culture != null)
{ {
_scriptFallbackCache.TryAdd(scriptKey, bestFamilyName); _scriptFallbackCache.TryAdd(scriptKey, bestFamilyName);
@ -90,25 +108,41 @@ namespace Avalonia.Media.Fonts
return true; return true;
} }
// --- Tier D: platform fallback (at most once per (script, culture)) --- // --- Tier D: platform fallback ---
var platformAlreadyAttempted = _scriptFallbackCache.ContainsKey(scriptKey); // 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
if (!platformAlreadyAttempted && // bucket, but that family may not cover THIS codepoint - e.g. 中 (U+4E2D) resolves to a CJK
TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt)) // 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); var hasCacheEntry = _scriptFallbackCache.TryGetValue(scriptKey, out var cachedFamily);
match = BuildTypefaceWithSynthesis(platformGt, key); var platformAlreadyAttempted = hasCacheEntry && cachedFamily is null;
return true;
}
if (!platformAlreadyAttempted) if (!platformAlreadyAttempted &&
{ TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt))
// Remember the negative answer so we don't ask the platform again. {
_scriptFallbackCache.TryAdd(scriptKey, null); // 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 --- // --- 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); match = BuildTypefaceWithSynthesis(lrGt, key);
return true; return true;
@ -124,6 +158,7 @@ namespace Avalonia.Media.Fonts
CultureInfo? culture, CultureInfo? culture,
Script script, Script script,
Script refinedScript, Script refinedScript,
Script shapingScript,
bool isLastResort, bool isLastResort,
[NotNullWhen(true)] out GlyphTypeface? bestGlyphTypeface, [NotNullWhen(true)] out GlyphTypeface? bestGlyphTypeface,
[NotNullWhen(true)] out string? bestFamilyName) [NotNullWhen(true)] out string? bestFamilyName)
@ -149,7 +184,7 @@ namespace Avalonia.Media.Fonts
continue; continue;
} }
if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, out var candidate)) if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, shapingScript, out var candidate))
{ {
continue; continue;
} }
@ -280,7 +315,7 @@ namespace Avalonia.Media.Fonts
/// <summary> /// <summary>
/// Hook for platform-backed collections (e.g. <see cref="SystemFontCollection"/>) to consult /// Hook for platform-backed collections (e.g. <see cref="SystemFontCollection"/>) to consult
/// the underlying font manager for a fallback typeface. Invoked at most once per /// the underlying font manager for a fallback typeface. Invoked at most once per
/// (script-bucket, culture) pair from <see cref="TryMatchCharacter"/>. /// (script-bucket, culture) pair from <see cref="FontCollectionBase.TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, string?, CultureInfo?, out Typeface)"/>.
/// </summary> /// </summary>
protected virtual bool TryMatchCharacterFromPlatform( protected virtual bool TryMatchCharacterFromPlatform(
int codepoint, int codepoint,
@ -293,23 +328,82 @@ namespace Avalonia.Media.Fonts
return false; return false;
} }
/// <summary>
/// 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.
/// </summary>
private bool TryGetCoveringMatchForFamily(
ConcurrentDictionary<FontCollectionKey, GlyphTypeface?> 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;
}
/// <summary>
/// When <paramref name="glyphTypeface"/> differs from the requested <paramref name="key"/> 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 <see cref="TryMatchCharacterFromPlatform"/>,
/// 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.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Picks a variant of the family that both is close to the requested key and actually maps /// 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 /// the requested codepoint. Falls back through the existing weight/stretch search but
/// filters every candidate through the font's character-to-glyph map. /// filters every candidate through the font's character-to-glyph map.
/// </summary> /// </summary>
// 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( private static bool TryGetCoveringMatch(
ConcurrentDictionary<FontCollectionKey, GlyphTypeface?> glyphTypefaces, ConcurrentDictionary<FontCollectionKey, GlyphTypeface?> glyphTypefaces,
FontCollectionKey key, FontCollectionKey key,
int codepoint, int codepoint,
bool isLastResort, bool isLastResort,
Script shapingScript,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface) [NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
{ {
// Exact key first. // Exact key first.
if (glyphTypefaces.TryGetValue(key, out glyphTypeface) && if (glyphTypefaces.TryGetValue(key, out glyphTypeface) &&
glyphTypeface != null && glyphTypeface != null &&
glyphTypeface.IsLastResort == isLastResort && glyphTypeface.IsLastResort == isLastResort &&
glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) &&
CanShape(glyphTypeface, shapingScript))
{ {
return true; return true;
} }
@ -330,7 +424,8 @@ namespace Avalonia.Media.Fonts
continue; continue;
} }
if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)) if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) ||
!CanShape(candidate, shapingScript))
{ {
continue; continue;
} }

70
src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs

@ -5,7 +5,7 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.Fonts namespace Avalonia.Media.Fonts
{ {
/// <summary> /// <summary>
/// Script-aware hints used by <see cref="FontCollectionBase.TryMatchCharacter"/> to /// Script-aware hints used by <see cref="FontCollectionBase.TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, string?, CultureInfo?, out Typeface)"/> to
/// disambiguate locale-sensitive scripts (e.g. CJK), to provide deterministic probe /// disambiguate locale-sensitive scripts (e.g. CJK), to provide deterministic probe
/// codepoints for scoring candidate fonts, and to decide whether a candidate font is /// codepoints for scoring candidate fonts, and to decide whether a candidate font is
/// compatible with the caller's culture. /// compatible with the caller's culture.
@ -177,5 +177,73 @@ namespace Avalonia.Media.Fonts
return bit >= 0; 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");
/// <summary>
/// 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.
/// </summary>
/// <returns>
/// <c>true</c> 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
/// <paramref name="secondary"/> equals <paramref name="primary"/>). <c>false</c> for scripts
/// that render acceptably from cmap alone (Latin, CJK, Hangul, Hebrew, Thai, …), for which no
/// layout-table gate should apply.
/// </returns>
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;
}
}
} }
} }

73
src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace Avalonia.Media.Fonts.Tables
{
/// <summary>
/// Reads the script tags declared in a font's OpenType Layout (<c>GSUB</c>/<c>GPOS</c>)
/// 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 <em>shape</em> a complex
/// script rather than merely map its codepoints through cmap.
/// <see href="https://learn.microsoft.com/typography/opentype/spec/chapter2#script-list-table-and-script-record"/>
/// </summary>
internal static class ScriptListTable
{
private static readonly OpenTypeTag s_gsub = OpenTypeTag.Parse("GSUB");
private static readonly OpenTypeTag s_gpos = OpenTypeTag.Parse("GPOS");
/// <summary>
/// Adds the GSUB and GPOS script tags declared by <paramref name="glyphTypeface"/> to
/// <paramref name="scriptTags"/>.
/// </summary>
/// <returns>
/// <c>false</c> 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.
/// </returns>
public static bool TryReadScriptTags(GlyphTypeface glyphTypeface, HashSet<OpenTypeTag> 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<OpenTypeTag> 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;
}
}
}
}

60
src/Avalonia.Base/Media/GlyphTypeface.cs

@ -44,6 +44,13 @@ namespace Avalonia.Media
private ITextShaperTypeface? _textShaperTypeface; private ITextShaperTypeface? _textShaperTypeface;
private UnicodeRange? _supportedUnicodeRange; 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<OpenTypeTag>? _shapingScriptTags;
private bool _shapingScriptTagsUnknown;
private readonly object _shapingScriptTagsLock = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="GlyphTypeface"/> class with the specified platform typeface and /// Initializes a new instance of the <see cref="GlyphTypeface"/> class with the specified platform typeface and
/// font simulations. /// font simulations.
@ -543,6 +550,59 @@ namespace Avalonia.Media
return false; return false;
} }
/// <summary>
/// Determines whether this font can <em>shape</em> 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 <c>true</c>. Used by the fallback itemizer
/// to avoid selecting a font that has the glyphs but cannot form them correctly.
/// </summary>
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<OpenTypeTag> EnsureShapingScriptTags()
{
var tags = _shapingScriptTags;
if (tags is not null)
{
return tags;
}
lock (_shapingScriptTagsLock)
{
if (_shapingScriptTags is not null)
{
return _shapingScriptTags;
}
var set = new HashSet<OpenTypeTag>();
// 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() private UnicodeRange BuildSupportedUnicodeRange()
{ {
var segments = new List<UnicodeRangeSegment>(); var segments = new List<UnicodeRangeSegment>();

243
src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs

@ -1,4 +1,6 @@
using System; using System;
using Avalonia.Logging;
using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Media.TextFormatting.Unicode;
using static Avalonia.Media.TextFormatting.FormattingObjectPool; using static Avalonia.Media.TextFormatting.FormattingObjectPool;
@ -9,7 +11,11 @@ namespace Avalonia.Media.TextFormatting
/// </summary> /// </summary>
public class TextCharacters : TextRun 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);
/// <summary> /// <summary>
/// Constructs a run for text content from a string. /// Constructs a run for text content from a string.
@ -95,60 +101,141 @@ namespace Avalonia.Media.TextFormatting
//Detect null terminator //Detect null terminator
if (count > 0) 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), allowPreviousTypeface = false;
biDiLevel);
} }
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), var requireFullCluster = pass == 0;
defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
}
}
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)) // 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
codepoint = grapheme.FirstCodepoint; // 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 = var fallbackCodepoint = GetFallbackCodepoint(textSpan, defaultGlyphTypeface);
fontManager.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight,
defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo, if (fontManager.TryMatchCharacter(fallbackCodepoint, defaultTypeface.Style, defaultTypeface.Weight,
out var fallbackTypeface); defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo,
shapingConstraint, out fallbackTypeface)
if (matchFound) && !fontManager.TryGetGlyphTypeface(fallbackTypeface, out fallbackGlyphTypeface))
{ {
// Fallback found // The platform matched a fallback family but its glyph typeface could not
if(fontManager.TryGetGlyphTypeface(fallbackTypeface, out var fallbackGlyphTypeface)) // be loaded; the cluster degrades to .notdef. Surface it for diagnosis.
{ Logger.TryGet(LogEventLevel.Warning, LogArea.Fonts)?.Log(null,
if (TryGetShapeableLength(textSpan, fallbackGlyphTypeface, defaultGlyphTypeface, out count)) "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), return new UnshapedTextRun(text.Slice(0, count),
biDiLevel); 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); var enumerator = new GraphemeEnumerator(textSpan);
//Move forward until we reach the next base character while (enumerator.MoveNext(out var grapheme))
while (enumerator.MoveNext(out 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; count += grapheme.Length;
@ -163,12 +250,18 @@ namespace Avalonia.Media.TextFormatting
/// <param name="text">The characters to shape.</param> /// <param name="text">The characters to shape.</param>
/// <param name="glyphTypeface">The typeface that is used to find matching characters.</param> /// <param name="glyphTypeface">The typeface that is used to find matching characters.</param>
/// <param name="defaultGlyphTypeface">The default typeface.</param> /// <param name="defaultGlyphTypeface">The default typeface.</param>
/// <param name="requireFullCluster">
/// When <c>true</c>, a grapheme cluster only counts as supported when the typeface has a glyph
/// for every scalar it contains (base plus combining marks); when <c>false</c>, only the base
/// scalar is tested.
/// </param>
/// <param name="length">The shapeable length.</param> /// <param name="length">The shapeable length.</param>
/// <returns></returns> /// <returns></returns>
internal static bool TryGetShapeableLength( internal static bool TryGetShapeableLength(
ReadOnlySpan<char> text, ReadOnlySpan<char> text,
GlyphTypeface glyphTypeface, GlyphTypeface glyphTypeface,
GlyphTypeface? defaultGlyphTypeface, GlyphTypeface? defaultGlyphTypeface,
bool requireFullCluster,
out int length) out int length)
{ {
length = 0; length = 0;
@ -192,17 +285,19 @@ namespace Avalonia.Media.TextFormatting
break; break;
} }
var clusterText = text.Slice(currentGrapheme.Offset, currentGrapheme.Length);
if (!currentCodepoint.IsWhiteSpace if (!currentCodepoint.IsWhiteSpace
&& defaultGlyphTypeface != null && defaultGlyphTypeface != null
&& defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _)) && ClusterIsCovered(clusterText, currentCodepoint, defaultGlyphTypeface, requireFullCluster))
{ {
break; break;
} }
//Stop at the first missing glyph //Stop at the first cluster this typeface can't render
if (!currentCodepoint.IsBreakChar && if (!currentCodepoint.IsBreakChar &&
currentCodepoint.GeneralCategory != GeneralCategory.Control && currentCodepoint.GeneralCategory != GeneralCategory.Control &&
!glyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _)) !ClusterIsCovered(clusterText, currentCodepoint, glyphTypeface, requireFullCluster))
{ {
break; break;
} }
@ -228,5 +323,75 @@ namespace Avalonia.Media.TextFormatting
return length > 0; return length > 0;
} }
/// <summary>
/// Determines whether <paramref name="glyphTypeface"/> can render the first grapheme cluster in
/// <paramref name="clusterText"/>. For a single-scalar cluster, or when
/// <paramref name="requireFullCluster"/> is <c>false</c>, 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.
/// </summary>
private static bool ClusterIsCovered(ReadOnlySpan<char> 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;
}
/// <summary>
/// Returns the first scalar of the run's first grapheme cluster that
/// <paramref name="defaultGlyphTypeface"/> 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.
/// </summary>
private static Codepoint GetFallbackCodepoint(ReadOnlySpan<char> 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;
}
} }
} }

BIN
tests/Avalonia.Skia.UnitTests/Fonts/NotoSansArabic-NoLayout.ttf

Binary file not shown.

BIN
tests/Avalonia.Skia.UnitTests/Fonts/NotoSansJP-Subset.ttf

Binary file not shown.

BIN
tests/Avalonia.Skia.UnitTests/Fonts/NotoSansSC-Subset.ttf

Binary file not shown.

3
tests/Avalonia.Skia.UnitTests/Media/FontCollectionDeterminismTests.cs

@ -2,6 +2,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Fonts; using Avalonia.Media.Fonts;
@ -12,7 +13,7 @@ using Xunit;
namespace Avalonia.Skia.UnitTests.Media namespace Avalonia.Skia.UnitTests.Media
{ {
/// <summary> /// <summary>
/// Verifies that <see cref="FontCollectionBase.TryMatchCharacter"/> resolves the same family /// Verifies that <see cref="FontCollectionBase.TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, string?, CultureInfo?, out Typeface)"/> resolves the same family
/// regardless of the order in which fonts were added to the collection, and is stable /// regardless of the order in which fonts were added to the collection, and is stable
/// across repeated invocations. /// across repeated invocations.
/// </summary> /// </summary>

245
tests/Avalonia.Skia.UnitTests/Media/FontCollectionTryMatchCharacterTests.cs

@ -14,7 +14,7 @@ using Xunit;
namespace Avalonia.Skia.UnitTests.Media namespace Avalonia.Skia.UnitTests.Media
{ {
/// <summary> /// <summary>
/// Exercises the tiered <see cref="FontCollectionBase.TryMatchCharacter"/> algorithm /// Exercises the tiered <see cref="FontCollectionBase.TryMatchCharacter(int, FontStyle, FontWeight, FontStretch, string?, CultureInfo?, out Typeface)"/> algorithm
/// (Tier A → Tier E) using a small set of embedded test fonts and a stub subclass /// (Tier A → Tier E) using a small set of embedded test fonts and a stub subclass
/// that intercepts the platform-fallback hook. /// that intercepts the platform-fallback hook.
/// </summary> /// </summary>
@ -220,6 +220,59 @@ namespace Avalonia.Skia.UnitTests.Media
null, null, out _)); 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] [Fact]
public void Match_Typeface_Carries_The_Requested_Style_Weight_And_Stretch() 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); 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, GlyphTypeface>
{
[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, GlyphTypeface>
{
[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() => private static IDisposable StartApp() =>
UnitTestApplication.Start(TestServices.MockPlatformRenderInterface.With(fontManagerImpl: new FontManagerImpl())); 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<IAssetLoader>();
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<IAssetLoader>();
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<IAssetLoader>();
var fontManagerImpl = AvaloniaLocator.Current.GetRequiredService<IFontManagerImpl>();
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) private static string FamilyOf(Typeface typeface)
{ {
// Fallback typefaces are returned with FontFamily.Name == "<collectionKey>#<familyName>". // Fallback typefaces are returned with FontFamily.Name == "<collectionKey>#<familyName>".
@ -296,5 +499,45 @@ namespace Avalonia.Skia.UnitTests.Media
return glyphTypeface is not null; 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<FontCollectionKey, GlyphTypeface> _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;
}
}
} }
} }

91
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<IFontManagerImpl>().ToConstant(fontManagerImpl);
var fontManager = new FontManager(fontManagerImpl);
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().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;
}
}
}

110
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<TextRun>? 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<IFontManagerImpl>().ToConstant(fontManagerImpl);
var fontManager = new FontManager(fontManagerImpl);
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().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;
}
}
}

308
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<TextRun>? 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<TextRun>? 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<TextRun>? 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<TextRun>? 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<IFontManagerImpl>().ToConstant(fontManagerImpl);
var fontManager = new FontManager(fontManagerImpl);
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().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;
}
}
}
Loading…
Cancel
Save