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