Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion shared-infrastructure
27 changes: 27 additions & 0 deletions src/SixLabors.Fonts/IFontFallbackResolver.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Resolves a font family for code points that no configured font can shape.
/// The shaping pipeline consults the resolver only after <see cref="TextOptions.Font"/> and every
/// <see cref="TextOptions.FallbackFontFamilies"/> entry have attempted the text, and at most once
/// per distinct unresolved code point per shaping operation.
/// </summary>
public interface IFontFallbackResolver
{
/// <summary>
/// Tries to resolve a font family containing a glyph for the given code point.
/// </summary>
/// <param name="codePoint">The code point no configured font can shape.</param>
/// <param name="requestedFamily">The family of the requested font, usable as a hint to bias matching toward stylistically compatible faces.</param>
/// <param name="style">The requested font style.</param>
/// <param name="culture">The culture used to select language specific faces, or <see langword="null"/>.</param>
/// <param name="family">When this method returns <see langword="true"/>, the resolved font family.</param>
/// <returns><see langword="true"/> if a family was resolved; otherwise, <see langword="false"/>.</returns>
public bool TryResolve(CodePoint codePoint, FontFamily requestedFamily, FontStyle style, CultureInfo? culture, out FontFamily family);
}
23 changes: 23 additions & 0 deletions src/SixLabors.Fonts/ShapingBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,29 @@ private FontGlyphMetrics GetGlyphMetrics(
return glyphMetrics;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="destination">The list receiving the unresolved code points.</param>
public void CollectUnresolvedCodePoints(List<CodePoint> 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);
}
}
}

/// <summary>
/// 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
Expand Down
41 changes: 41 additions & 0 deletions src/SixLabors.Fonts/SystemFontFallbackResolver.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public sealed class SystemFontFallbackResolver : IFontFallbackResolver
{
/// <summary>
/// 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.
/// </summary>
private readonly ConcurrentDictionary<(int CodePoint, string Family, FontStyle Style, string Culture), (bool Matched, FontFamily Family)> cache = new();

/// <inheritdoc/>
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;
}
}
9 changes: 9 additions & 0 deletions src/SixLabors.Fonts/SystemFonts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ public static class SystemFonts
{
private static readonly Lazy<SystemFontCollection> LazySystemFonts = new(() => new SystemFontCollection(), true);

private static readonly Lazy<SystemFontFallbackResolver> LazyFallbackResolver = new(() => new SystemFontFallbackResolver(), true);

/// <summary>
/// Gets the collection containing the globally installed system fonts.
/// </summary>
public static IReadOnlySystemFontCollection Collection => LazySystemFonts.Value;

/// <summary>
/// Gets a resolver that selects fallback font families from the installed system fonts.
/// Assign it to <see cref="TextOptions.FontFallbackResolver"/> to let shaping consult the
/// operating system for code points no configured font can shape.
/// </summary>
public static IFontFallbackResolver FallbackResolver => LazyFallbackResolver.Value;

/// <summary>
/// Gets the collection of <see cref="FontFamily"/>s installed on current system.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions src/SixLabors.Fonts/TextOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public TextOptions(TextOptions options)
this.Font = options.Font;
this.FontWeight = options.FontWeight;
this.FallbackFontFamilies = new List<FontFamily>(options.FallbackFontFamilies);
this.FontFallbackResolver = options.FontFallbackResolver;
this.TabWidth = options.TabWidth;
this.HintingMode = options.HintingMode;
this.Dpi = options.Dpi;
Expand Down Expand Up @@ -93,6 +94,18 @@ public Font Font
/// </summary>
public IReadOnlyList<FontFamily> FallbackFontFamilies { get; set; } = Array.Empty<FontFamily>();

/// <summary>
/// Gets or sets the resolver consulted for code points that neither <see cref="Font"/> nor any
/// <see cref="FallbackFontFamilies"/> entry can shape, or <see langword="null"/> to leave such
/// code points rendered as the missing-glyph outline.
/// </summary>
/// <remarks>
/// Resolution happens per distinct unresolved code point after every configured font has
/// attempted the text. <see cref="SystemFonts.FallbackResolver"/> selects from the fonts
/// installed on the current machine, so rendered output can differ between machines.
/// </remarks>
public IFontFallbackResolver? FontFallbackResolver { get; set; }

/// <summary>
/// Gets or sets the DPI (Dots Per Inch) to render/measure the text at.
/// <para/>
Expand Down
95 changes: 94 additions & 1 deletion src/SixLabors.Fonts/TextShaper.Pipeline.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -299,7 +300,7 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan<char> 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;
Expand Down Expand Up @@ -392,11 +393,47 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan<char> 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<Font>? resolverFonts = null;
if (!complete && options.FontFallbackResolver is IFontFallbackResolver resolver)
{
List<CodePoint> unresolved = [];
HashSet<int> queriedCodePoints = [];
HashSet<string> 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.
Expand All @@ -420,6 +457,14 @@ private static ShapingBuffer ShapeCore(ReadOnlySpan<char> 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.
Expand Down Expand Up @@ -698,6 +743,54 @@ private static void HideDefaultIgnorables(ShapingBuffer shaped)
}
}

/// <summary>
/// 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
/// <paramref name="queriedCodePoints"/>' complement, both sets only grow, and the
/// candidates come from the text's finite code points — so repeated calls must
/// eventually return <see langword="false"/> and the caller's loop is bounded by the
/// number of distinct unresolved code points.
/// </summary>
/// <param name="positionings">The accumulator buffer holding the shaped records.</param>
/// <param name="resolver">The configured fallback resolver.</param>
/// <param name="options">The text options supplying the requested family, size, style, and culture.</param>
/// <param name="unresolved">The reusable scratch list receiving the unresolved code points.</param>
/// <param name="queriedCodePoints">The code points already sent to the resolver, matched or not.</param>
/// <param name="attemptedFamilies">The family names already shaped with.</param>
/// <param name="font">When this method returns <see langword="true"/>, the font for the next pass.</param>
/// <returns><see langword="true"/> if a new family was resolved; otherwise, <see langword="false"/>.</returns>
private static bool TryGetNextResolverFont(
ShapingBuffer positionings,
IFontFallbackResolver resolver,
TextOptions options,
List<CodePoint> unresolved,
HashSet<int> queriedCodePoints,
HashSet<string> 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;
}

/// <summary>
/// Shapes a single font run — maps codepoints in <paramref name="text"/> to glyph ids using
/// <paramref name="font"/>, then runs GSUB substitution and GPOS positioning. Codepoints that
Expand Down
Loading
Loading