diff --git a/shared-infrastructure b/shared-infrastructure
index 74b7f32b8..20f15a2d4 160000
--- a/shared-infrastructure
+++ b/shared-infrastructure
@@ -1 +1 @@
-Subproject commit 74b7f32b8e41fdf8fe2f3eda54fd5a82ebbedfbc
+Subproject commit 20f15a2d47bba9d8375b5fd34fac283336ec9a5e
diff --git a/src/SixLabors.Fonts/IFontFallbackResolver.cs b/src/SixLabors.Fonts/IFontFallbackResolver.cs
new file mode 100644
index 000000000..5ea009aac
--- /dev/null
+++ b/src/SixLabors.Fonts/IFontFallbackResolver.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Resolves a font family for code points that no configured font can shape.
+/// The shaping pipeline consults the resolver only after and every
+/// entry have attempted the text, and at most once
+/// per distinct unresolved code point per shaping operation.
+///
+public interface IFontFallbackResolver
+{
+ ///
+ /// Tries to resolve a font family containing a glyph for the given code point.
+ ///
+ /// The code point no configured font can shape.
+ /// The family of the requested font, usable as a hint to bias matching toward stylistically compatible faces.
+ /// The requested font style.
+ /// The culture used to select language specific faces, or .
+ /// When this method returns , the resolved font family.
+ /// if a family was resolved; otherwise, .
+ public bool TryResolve(CodePoint codePoint, FontFamily requestedFamily, FontStyle style, CultureInfo? culture, out FontFamily family);
+}
diff --git a/src/SixLabors.Fonts/ShapingBuffer.cs b/src/SixLabors.Fonts/ShapingBuffer.cs
index bfe1d7963..8bafdaac7 100644
--- a/src/SixLabors.Fonts/ShapingBuffer.cs
+++ b/src/SixLabors.Fonts/ShapingBuffer.cs
@@ -1982,6 +1982,29 @@ private FontGlyphMetrics GetGlyphMetrics(
return glyphMetrics;
}
+ ///
+ /// Collects the code points of records still carrying fallback metrics, in buffer order
+ /// and including repeats. Placeholders never resolve through fonts and controls keep
+ /// their synthetic fallback metrics by design, so both are excluded.
+ ///
+ /// The list receiving the unresolved code points.
+ public void CollectUnresolvedCodePoints(List destination)
+ {
+ for (int i = 0; i < this.Count; i++)
+ {
+ ref GlyphShapingData slot = ref this.data[i];
+ if (slot.IsPlaceholder || CodePoint.IsControl(slot.CodePoint))
+ {
+ continue;
+ }
+
+ if (this.metrics[i].Metrics.GlyphType == GlyphType.Fallback)
+ {
+ destination.Add(slot.CodePoint);
+ }
+ }
+ }
+
///
/// Marks the glyph at the specified index as positioned. Positions accumulate in
/// the position entry's shaping bounds and are read from there by consumers, so
diff --git a/src/SixLabors.Fonts/SystemFontFallbackResolver.cs b/src/SixLabors.Fonts/SystemFontFallbackResolver.cs
new file mode 100644
index 000000000..633055c04
--- /dev/null
+++ b/src/SixLabors.Fonts/SystemFontFallbackResolver.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Collections.Concurrent;
+using System.Globalization;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Resolves fallback font families from the fonts installed on the current machine using the
+/// operating system's character-to-font matching service.
+/// Results depend on the machine's installed fonts: the same input can resolve different
+/// families, or none, on different machines.
+///
+public sealed class SystemFontFallbackResolver : IFontFallbackResolver
+{
+ ///
+ /// Match results per code point, requested family, style, and culture. Misses are cached
+ /// alongside hits so repeated text costs one native query per distinct key regardless of
+ /// outcome. The requested family participates because it biases the native match.
+ ///
+ private readonly ConcurrentDictionary<(int CodePoint, string Family, FontStyle Style, string Culture), (bool Matched, FontFamily Family)> cache = new();
+
+ ///
+ public bool TryResolve(CodePoint codePoint, FontFamily requestedFamily, FontStyle style, CultureInfo? culture, out FontFamily family)
+ {
+ // The requested family name biases each platform's match toward stylistically
+ // compatible faces. A name unknown to the system degrades to an unbiased match on
+ // every platform, so file-loaded families need no special handling.
+ (bool Matched, FontFamily Family) result = this.cache.GetOrAdd(
+ (codePoint.Value, requestedFamily.Name, style, culture?.Name ?? string.Empty),
+ static (_, arg) => SystemFonts.Collection.TryMatchCharacter(arg.CodePoint, arg.Style, arg.Family, arg.Culture, out FontMatch match)
+ ? (true, match.Family)
+ : (false, default),
+ (CodePoint: codePoint, Family: requestedFamily.Name, Style: style, Culture: culture));
+
+ family = result.Family;
+ return result.Matched;
+ }
+}
diff --git a/src/SixLabors.Fonts/SystemFonts.cs b/src/SixLabors.Fonts/SystemFonts.cs
index 0f4c8e0b8..754fc6de4 100644
--- a/src/SixLabors.Fonts/SystemFonts.cs
+++ b/src/SixLabors.Fonts/SystemFonts.cs
@@ -14,11 +14,20 @@ public static class SystemFonts
{
private static readonly Lazy LazySystemFonts = new(() => new SystemFontCollection(), true);
+ private static readonly Lazy LazyFallbackResolver = new(() => new SystemFontFallbackResolver(), true);
+
///
/// Gets the collection containing the globally installed system fonts.
///
public static IReadOnlySystemFontCollection Collection => LazySystemFonts.Value;
+ ///
+ /// Gets a resolver that selects fallback font families from the installed system fonts.
+ /// Assign it to to let shaping consult the
+ /// operating system for code points no configured font can shape.
+ ///
+ public static IFontFallbackResolver FallbackResolver => LazyFallbackResolver.Value;
+
///
/// Gets the collection of s installed on current system.
///
diff --git a/src/SixLabors.Fonts/TextOptions.cs b/src/SixLabors.Fonts/TextOptions.cs
index ad03a7980..d9b499914 100644
--- a/src/SixLabors.Fonts/TextOptions.cs
+++ b/src/SixLabors.Fonts/TextOptions.cs
@@ -33,6 +33,7 @@ public TextOptions(TextOptions options)
this.Font = options.Font;
this.FontWeight = options.FontWeight;
this.FallbackFontFamilies = new List(options.FallbackFontFamilies);
+ this.FontFallbackResolver = options.FontFallbackResolver;
this.TabWidth = options.TabWidth;
this.HintingMode = options.HintingMode;
this.Dpi = options.Dpi;
@@ -93,6 +94,18 @@ public Font Font
///
public IReadOnlyList FallbackFontFamilies { get; set; } = Array.Empty();
+ ///
+ /// Gets or sets the resolver consulted for code points that neither nor any
+ /// entry can shape, or to leave such
+ /// code points rendered as the missing-glyph outline.
+ ///
+ ///
+ /// Resolution happens per distinct unresolved code point after every configured font has
+ /// attempted the text. selects from the fonts
+ /// installed on the current machine, so rendered output can differ between machines.
+ ///
+ public IFontFallbackResolver? FontFallbackResolver { get; set; }
+
///
/// Gets or sets the DPI (Dots Per Inch) to render/measure the text at.
///
diff --git a/src/SixLabors.Fonts/TextShaper.Pipeline.cs b/src/SixLabors.Fonts/TextShaper.Pipeline.cs
index 26a8d3bb8..1d1dfb527 100644
--- a/src/SixLabors.Fonts/TextShaper.Pipeline.cs
+++ b/src/SixLabors.Fonts/TextShaper.Pipeline.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using SixLabors.Fonts.Tables.AdvancedTypographic;
using SixLabors.Fonts.Unicode;
@@ -299,7 +300,7 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan text, TextOptions opti
complete = substitutions.SeedMetricsInPlace(onlyRun.ResolvedFont);
- if (complete || fallbackFonts.Length == 0)
+ if (complete || (fallbackFonts.Length == 0 && options.FontFallbackResolver is null))
{
substitutions.SetRole(ShapingBufferRole.Positioning);
shaped = substitutions;
@@ -392,11 +393,47 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan text, TextOptions opti
substitutions,
positionings))
{
+ complete = true;
break;
}
}
}
+ // Last-resort resolver passes: one whole-buffer pass per newly resolved family.
+ // The path only runs when unresolved code points remain, so its collections are
+ // transient.
+ List? resolverFonts = null;
+ if (!complete && options.FontFallbackResolver is IFontFallbackResolver resolver)
+ {
+ List unresolved = [];
+ HashSet queriedCodePoints = [];
+ HashSet attemptedFamilies = [];
+
+ while (!complete && TryGetNextResolverFont(positionings, resolver, options, unresolved, queriedCodePoints, attemptedFamilies, out Font? next))
+ {
+ (resolverFonts ??= []).Add(next);
+
+ textRunIndex = 0;
+ codePointIndex = 0;
+ stringIndex = 0;
+ bidiRunIndex = 0;
+ complete = DoFontRun(
+ text,
+ 0,
+ textRuns,
+ ref textRunIndex,
+ ref codePointIndex,
+ ref stringIndex,
+ ref bidiRunIndex,
+ true,
+ next,
+ bidiRuns,
+ bidiMap,
+ substitutions,
+ positionings);
+ }
+ }
+
// Update the positions of the glyphs in the completed buffer.
// Each set of metrics is associated with single font and will only be updated
// by that font so it's safe to use a single buffer.
@@ -420,6 +457,14 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan text, TextOptions opti
font.FontMetrics.UpdatePositions(shaped);
}
+ if (resolverFonts is not null)
+ {
+ foreach (Font font in resolverFonts)
+ {
+ font.FontMetrics.UpdatePositions(shaped);
+ }
+ }
+
// Script-specific expansion runs only after every font has finished
// positioning. Process segments from the end so an expansion cannot move
// the not-yet-processed range of an earlier segment.
@@ -698,6 +743,54 @@ private static void HideDefaultIgnorables(ShapingBuffer shaped)
}
}
+ ///
+ /// Finds the next font for a resolver fallback pass: re-collects the still-unresolved
+ /// code points, then queries the resolver for each code point not queried before until
+ /// one yields a family not shaped with before.
+ /// Termination is structural: a successful return consumes at least one code point from
+ /// ' complement, both sets only grow, and the
+ /// candidates come from the text's finite code points — so repeated calls must
+ /// eventually return and the caller's loop is bounded by the
+ /// number of distinct unresolved code points.
+ ///
+ /// The accumulator buffer holding the shaped records.
+ /// The configured fallback resolver.
+ /// The text options supplying the requested family, size, style, and culture.
+ /// The reusable scratch list receiving the unresolved code points.
+ /// The code points already sent to the resolver, matched or not.
+ /// The family names already shaped with.
+ /// When this method returns , the font for the next pass.
+ /// if a new family was resolved; otherwise, .
+ private static bool TryGetNextResolverFont(
+ ShapingBuffer positionings,
+ IFontFallbackResolver resolver,
+ TextOptions options,
+ List unresolved,
+ HashSet queriedCodePoints,
+ HashSet attemptedFamilies,
+ [NotNullWhen(true)] out Font? font)
+ {
+ unresolved.Clear();
+ positionings.CollectUnresolvedCodePoints(unresolved);
+
+ foreach (CodePoint codePoint in unresolved)
+ {
+ if (!queriedCodePoints.Add(codePoint.Value))
+ {
+ continue;
+ }
+
+ if (resolver.TryResolve(codePoint, options.Font.Family, options.Font.RequestedStyle, options.Culture, out FontFamily family) && attemptedFamilies.Add(family.Name))
+ {
+ font = new Font(family, options.Font.Size, options.Font.RequestedStyle);
+ return true;
+ }
+ }
+
+ font = null;
+ return false;
+ }
+
///
/// Shapes a single font run — maps codepoints in to glyph ids using
/// , then runs GSUB substitution and GPOS positioning. Codepoints that
diff --git a/tests/SixLabors.Fonts.Tests/FontFallbackResolverTests.cs b/tests/SixLabors.Fonts.Tests/FontFallbackResolverTests.cs
new file mode 100644
index 000000000..d6f0ac482
--- /dev/null
+++ b/tests/SixLabors.Fonts.Tests/FontFallbackResolverTests.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts.Tests;
+
+public class FontFallbackResolverTests
+{
+ ///
+ /// Records every query and answers with one fixed family, or with no match when the
+ /// family is null. Deterministic: no system fonts are consulted.
+ ///
+ private sealed class RecordingResolver : IFontFallbackResolver
+ {
+ private readonly FontFamily? family;
+
+ public RecordingResolver(FontFamily? family) => this.family = family;
+
+ public List QueriedCodePoints { get; } = [];
+
+ public CultureInfo? LastCulture { get; private set; }
+
+ public FontFamily LastRequestedFamily { get; private set; }
+
+ public bool TryResolve(CodePoint codePoint, FontFamily requestedFamily, FontStyle style, CultureInfo? culture, out FontFamily family)
+ {
+ this.QueriedCodePoints.Add(codePoint.Value);
+ this.LastCulture = culture;
+ this.LastRequestedFamily = requestedFamily;
+
+ if (this.family is FontFamily resolved)
+ {
+ family = resolved;
+ return true;
+ }
+
+ family = default;
+ return false;
+ }
+ }
+
+ [Fact]
+ public void ResolverSuppliesFamilyForUnresolvedCodePoint()
+ {
+ Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 12);
+ FontFamily emoji = TestFonts.GetFont(TestFonts.TwemojiMozillaFile, 12).Family;
+ RecordingResolver resolver = new(emoji);
+ CultureInfo culture = CultureInfo.GetCultureInfo("en-GB");
+
+ // Open Sans cannot shape the emoji; the resolver supplies the family that can,
+ // and the emoji then renders through its COLR layers.
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "A😀", new TextOptions(font)
+ {
+ ColorFontSupport = ColorFontSupport.ColrV0,
+ FontFallbackResolver = resolver,
+ Culture = culture
+ });
+
+ Assert.Equal(3, renderer.Colors.Count);
+ Assert.Equal([0x1F600], resolver.QueriedCodePoints);
+ Assert.Equal(culture, resolver.LastCulture);
+ Assert.Equal(font.Family, resolver.LastRequestedFamily);
+ }
+
+ [Fact]
+ public void ResolverNotConsultedWhenPrimaryFontCovers()
+ {
+ Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 12);
+ RecordingResolver resolver = new(font.Family);
+
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "AB", new TextOptions(font)
+ {
+ FontFallbackResolver = resolver
+ });
+
+ Assert.Empty(resolver.QueriedCodePoints);
+ }
+
+ [Fact]
+ public void ExplicitFallbackFamiliesWinOverResolver()
+ {
+ Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 12);
+ FontFamily emoji = TestFonts.GetFont(TestFonts.TwemojiMozillaFile, 12).Family;
+ RecordingResolver resolver = new(emoji);
+
+ // The explicit fallback list already covers the emoji, so the resolver is the
+ // last resort and must never be queried.
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "😀", new TextOptions(font)
+ {
+ ColorFontSupport = ColorFontSupport.ColrV0,
+ FallbackFontFamilies = [emoji],
+ FontFallbackResolver = resolver
+ });
+
+ Assert.Equal(3, renderer.Colors.Count);
+ Assert.Empty(resolver.QueriedCodePoints);
+ }
+
+ [Fact]
+ public void ResolverReturningNonCoveringFamilyTerminates()
+ {
+ Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 12);
+ RecordingResolver resolver = new(font.Family);
+
+ // The resolver answers with the same family that already failed to shape the
+ // emoji. The pass must run once, resolve nothing, and stop: one query per
+ // distinct code point, no colors, no hang.
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "😀😀", new TextOptions(font)
+ {
+ ColorFontSupport = ColorFontSupport.ColrV0,
+ FontFallbackResolver = resolver
+ });
+
+ Assert.Empty(renderer.Colors);
+ Assert.Equal([0x1F600], resolver.QueriedCodePoints);
+ }
+
+ [Fact]
+ public void ResolverFailureLeavesMissingGlyph()
+ {
+ Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 12);
+ RecordingResolver resolver = new(null);
+
+ ColorGlyphRenderer renderer = new();
+ TextRenderer.RenderTo(renderer, "😀", new TextOptions(font)
+ {
+ ColorFontSupport = ColorFontSupport.ColrV0,
+ FontFallbackResolver = resolver
+ });
+
+ Assert.Empty(renderer.Colors);
+ Assert.Equal([0x1F600], resolver.QueriedCodePoints);
+ }
+}
diff --git a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
index 795348f5b..0e39a31f2 100644
--- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
+++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj
@@ -28,7 +28,7 @@
Comment out this constant declaration to disable all tests based upon image generation.
This allows us to make breaking changes to the Fonts API without breaking the tests.
-->
-
+ $(DefineConstants);SUPPORTS_DRAWING
true
@@ -47,7 +47,7 @@
-
+
diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
index d640c8dda..bdb9f4403 100644
--- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
+++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs
@@ -164,6 +164,7 @@ private static RichTextOptions FromTextOptions(TextOptions options, bool customD
{
FontWeight = options.FontWeight,
FallbackFontFamilies = new List(options.FallbackFontFamilies),
+ FontFallbackResolver = options.FontFallbackResolver,
TabWidth = options.TabWidth,
HintingMode = options.HintingMode,
Dpi = options.Dpi,
diff --git a/tests/harfbuzz b/tests/harfbuzz
index 34baf8d4c..d2df3cdcc 160000
--- a/tests/harfbuzz
+++ b/tests/harfbuzz
@@ -1 +1 @@
-Subproject commit 34baf8d4cd0afc333ee0a601ba6fefa859b37248
+Subproject commit d2df3cdcc0836299a163dc0399a6b047d19ac56c