// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
///
/// Manages font fallback for text rendering when the primary font
/// doesn't contain glyphs for certain characters (emoji, CJK, etc.).
///
public class FontFallbackManager
{
private static FontFallbackManager? _instance;
private static readonly object _lock = new();
///
/// Gets the singleton instance of the font fallback manager.
///
public static FontFallbackManager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= new FontFallbackManager();
}
}
return _instance;
}
}
// Fallback font chain ordered by priority
private readonly string[] _fallbackFonts = new[]
{
// Primary sans-serif fonts
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
"FreeSans",
// Emoji fonts
"Noto Color Emoji",
"Noto Emoji",
"Symbola",
"Segoe UI Emoji",
// CJK fonts (Chinese, Japanese, Korean)
"Noto Sans CJK SC",
"Noto Sans CJK TC",
"Noto Sans CJK JP",
"Noto Sans CJK KR",
"WenQuanYi Micro Hei",
"WenQuanYi Zen Hei",
"Droid Sans Fallback",
// Arabic and RTL scripts
"Noto Sans Arabic",
"Noto Naskh Arabic",
"DejaVu Sans",
// Indic scripts
"Noto Sans Devanagari",
"Noto Sans Tamil",
"Noto Sans Bengali",
"Noto Sans Telugu",
// Thai
"Noto Sans Thai",
"Loma",
// Hebrew
"Noto Sans Hebrew",
// System fallbacks
"Sans",
"sans-serif"
};
// Cache for typeface lookups
private readonly Dictionary _typefaceCache = new();
private readonly Dictionary<(int codepoint, string preferredFont), SKTypeface?> _glyphCache = new();
private FontFallbackManager()
{
// Pre-cache common fallback fonts
foreach (var fontName in _fallbackFonts.Take(10))
{
GetCachedTypeface(fontName);
}
}
///
/// Gets a typeface that can render the specified codepoint.
/// Falls back through the font chain if the preferred font doesn't support it.
///
/// The Unicode codepoint to render.
/// The preferred typeface to use.
/// A typeface that can render the codepoint, or the preferred typeface as fallback.
public SKTypeface GetTypefaceForCodepoint(int codepoint, SKTypeface preferred)
{
// Check cache first
var cacheKey = (codepoint, preferred.FamilyName);
if (_glyphCache.TryGetValue(cacheKey, out var cached))
{
return cached ?? preferred;
}
// Check if preferred font has the glyph
if (TypefaceContainsGlyph(preferred, codepoint))
{
_glyphCache[cacheKey] = preferred;
return preferred;
}
// Search fallback fonts
foreach (var fontName in _fallbackFonts)
{
var fallback = GetCachedTypeface(fontName);
if (fallback != null && TypefaceContainsGlyph(fallback, codepoint))
{
_glyphCache[cacheKey] = fallback;
return fallback;
}
}
// No fallback found, return preferred (will show tofu)
_glyphCache[cacheKey] = null;
return preferred;
}
///
/// Gets a typeface that can render all codepoints in the text.
/// For mixed scripts, use ShapeTextWithFallback instead.
///
public SKTypeface GetTypefaceForText(string text, SKTypeface preferred)
{
if (string.IsNullOrEmpty(text))
return preferred;
// Check first non-ASCII character
foreach (var rune in text.EnumerateRunes())
{
if (rune.Value > 127)
{
return GetTypefaceForCodepoint(rune.Value, preferred);
}
}
return preferred;
}
///
/// Shapes text with automatic font fallback for mixed scripts.
/// Returns a list of text runs, each with its own typeface.
///
public List ShapeTextWithFallback(string text, SKTypeface preferred)
{
var runs = new List();
if (string.IsNullOrEmpty(text))
return runs;
var currentRun = new StringBuilder();
SKTypeface? currentTypeface = null;
int runStart = 0;
int charIndex = 0;
foreach (var rune in text.EnumerateRunes())
{
var typeface = GetTypefaceForCodepoint(rune.Value, preferred);
if (currentTypeface == null)
{
currentTypeface = typeface;
}
else if (typeface.FamilyName != currentTypeface.FamilyName)
{
// Typeface changed - save current run
if (currentRun.Length > 0)
{
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
}
currentRun.Clear();
currentTypeface = typeface;
runStart = charIndex;
}
currentRun.Append(rune.ToString());
charIndex += rune.Utf16SequenceLength;
}
// Add final run
if (currentRun.Length > 0 && currentTypeface != null)
{
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
}
return runs;
}
///
/// Checks if a typeface is available on the system.
///
public bool IsFontAvailable(string fontFamily)
{
var typeface = GetCachedTypeface(fontFamily);
return typeface != null && typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase);
}
///
/// Gets a list of available fallback fonts on this system.
///
public IEnumerable GetAvailableFallbackFonts()
{
foreach (var fontName in _fallbackFonts)
{
if (IsFontAvailable(fontName))
{
yield return fontName;
}
}
}
private SKTypeface? GetCachedTypeface(string fontFamily)
{
if (_typefaceCache.TryGetValue(fontFamily, out var cached))
{
return cached;
}
var typeface = SKTypeface.FromFamilyName(fontFamily);
// Check if we actually got the requested font or a substitution
if (typeface != null && !typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase))
{
// Got a substitution, don't cache it as the requested font
typeface = null;
}
_typefaceCache[fontFamily] = typeface;
return typeface;
}
private bool TypefaceContainsGlyph(SKTypeface typeface, int codepoint)
{
// Use SKFont to check glyph coverage
using var font = new SKFont(typeface, 12);
var glyphs = new ushort[1];
var chars = char.ConvertFromUtf32(codepoint);
font.GetGlyphs(chars, glyphs);
// Glyph ID 0 is the "missing glyph" (tofu)
return glyphs[0] != 0;
}
}
///
/// Represents a run of text with a specific typeface.
///
public class TextRun
{
///
/// The text content of this run.
///
public string Text { get; }
///
/// The typeface to use for this run.
///
public SKTypeface Typeface { get; }
///
/// The starting character index in the original string.
///
public int StartIndex { get; }
public TextRun(string text, SKTypeface typeface, int startIndex)
{
Text = text;
Typeface = typeface;
StartIndex = startIndex;
}
}
///
/// StringBuilder for internal use.
///
file class StringBuilder
{
private readonly List _chars = new();
public int Length => _chars.Count;
public void Append(string s)
{
_chars.AddRange(s);
}
public void Clear()
{
_chars.Clear();
}
public override string ToString()
{
return new string(_chars.ToArray());
}
}