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 Avalonia.Logging;
using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
@ -262,6 +263,17 @@ namespace Avalonia.Media
/// </returns>
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);
/// <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)
{
@ -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);
}

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

@ -20,8 +20,10 @@ namespace Avalonia.Media.Fonts
// Make this internal for testing purposes
internal readonly ConcurrentDictionary<string, ConcurrentDictionary<FontCollectionKey, GlyphTypeface?>> _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<ScriptFallbackKey, string?> _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);
/// <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;
@ -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
/// <summary>
/// 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
/// (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>
protected virtual bool TryMatchCharacterFromPlatform(
int codepoint,
@ -293,23 +328,82 @@ namespace Avalonia.Media.Fonts
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>
/// 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.
/// </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(
ConcurrentDictionary<FontCollectionKey, GlyphTypeface?> 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;
}

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

@ -5,7 +5,7 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.Fonts
{
/// <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
/// 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");
/// <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 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>
/// Initializes a new instance of the <see cref="GlyphTypeface"/> class with the specified platform typeface and
/// font simulations.
@ -543,6 +550,59 @@ namespace Avalonia.Media
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()
{
var segments = new List<UnicodeRangeSegment>();

243
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
/// </summary>
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>
/// 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
/// <param name="text">The characters to shape.</param>
/// <param name="glyphTypeface">The typeface that is used to find matching characters.</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>
/// <returns></returns>
internal static bool TryGetShapeableLength(
ReadOnlySpan<char> 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;
}
/// <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.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
{
/// <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
/// across repeated invocations.
/// </summary>

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

@ -14,7 +14,7 @@ using Xunit;
namespace Avalonia.Skia.UnitTests.Media
{
/// <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
/// that intercepts the platform-fallback hook.
/// </summary>
@ -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, 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() =>
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)
{
// Fallback typefaces are returned with FontFamily.Name == "<collectionKey>#<familyName>".
@ -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<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