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