A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

591 lines
30 KiB

#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";
// A second broad fallback that covers Latin plus a range of combining marks but has no glyph
// for U+FE0F - unlike DejaVu Sans, which maps the variation selector and would therefore hide
// the default-ignorable bug.
private const string NoVariationSelectorFallbackFont = "Avalonia.Skia.UnitTests.Assets.NotoSans-Italic.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";
// A colour emoji font, of the kind every platform ships: it covers the emoji block and, like
// practically every font, U+0020 - at an advance of its own that is not the primary's.
private const string EmojiFont = "Avalonia.Skia.UnitTests.Assets.TwitterColorEmoji-SVGinOT.ttf";
// U+1F642 🙂 — covered by the emoji font only.
private const int EmojiCodepoint = 0x1F642;
// 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<TextRun>? 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 _));
var mark = FindMarkCoveredByFallbackOnly(fontManager, defaultGlyphTypeface, defaultFontFamily,
baseCodepoint);
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<TextRun>? toReturn = results;
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
}
}
}
// A default ignorable codepoint inside a cluster must not be treated as content the font has to
// cover. U+FE0F is a nonspacing mark by general category, so it used to be demanded from every
// candidate font; no text font maps it, which made the whole base+VS16+mark cluster unmatchable
// and dropped it back onto the primary font (rendering the mark as .notdef).
[Fact]
public void GetShapeableCharacters_Ignores_Default_Ignorable_Codepoints_When_Matching_Cluster_Coverage()
{
using (Start(PrimaryFont, NoVariationSelectorFallbackFont))
{
var fontManager = FontManager.Current;
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
var defaultFontFamily = defaultProperties.Typeface.FontFamily;
const int baseCodepoint = 'a';
const char variationSelector16 = '\uFE0F';
Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(baseCodepoint, out _));
// The premise: no font here has a glyph for the variation selector, and none is expected
// to - it is default ignorable.
Assert.False(fontManager.TryMatchCharacter(variationSelector16, FontStyle.Normal,
FontWeight.Normal, FontStretch.Normal, defaultFontFamily, null, out _));
var mark = FindMarkCoveredByFallbackOnly(fontManager, defaultGlyphTypeface, defaultFontFamily,
baseCodepoint);
var text = ("a" + variationSelector16 + 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);
Assert.NotEmpty(results);
var firstRun = results[0];
Assert.Equal(text.Length, firstRun.Length);
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<TextRun>? toReturn = results;
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
}
}
}
// Probes for a combining mark the primary font lacks but a fallback covers together with the
// base. Probing keeps the tests robust to the exact coverage of the embedded fonts.
private static int FindMarkCoveredByFallbackOnly(FontManager fontManager, GlyphTypeface primary,
FontFamily primaryFontFamily, int baseCodepoint)
{
foreach (var candidate in CombiningMarkCandidates)
{
if (primary.CharacterToGlyphMap.TryGetGlyph(candidate, out _))
{
continue; // primary already covers it - not a useful probe
}
if (fontManager.TryMatchCharacter(candidate, FontStyle.Normal, FontWeight.Normal,
FontStretch.Normal, primaryFontFamily, null, out var markTypeface)
&& fontManager.TryGetGlyphTypeface(markTypeface, out var markGlyphTypeface)
&& markGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(baseCodepoint, out _))
{
return candidate;
}
}
Assert.Fail(
"No combining mark found that the primary font lacks but a fallback covers together with the base.");
return 0;
}
// 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<TextRun>? 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<TextRun>? toReturn = results;
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
}
}
}
// A fallback run must end where the primary font regains coverage, whitespace included.
// Practically every font maps U+0020, so a run that is extended for as long as the fallback
// has glyphs swallows the space that follows the fallback text and shapes it with the
// fallback's space glyph - which is a full em in most emoji fonts.
[Fact]
public void GetShapeableCharacters_Does_Not_Absorb_Whitespace_Into_A_Fallback_Run()
{
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 lacks the Hebrew letter but covers both the space and the
// letter after it, and the fallback that covers the Hebrew letter maps the space too -
// which is what lets the fallback run reach past the letter today.
Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(FallbackCodepoint, out _));
Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph('b', out _));
Assert.True(fontManager.TryMatchCharacter(FallbackCodepoint, FontStyle.Normal, FontWeight.Normal,
FontStretch.Normal, defaultFontFamily, null, out var fallbackTypeface));
Assert.True(fontManager.TryGetGlyphTypeface(fallbackTypeface, out var fallbackGlyphTypeface));
Assert.True(fallbackGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
var text = (char.ConvertFromUtf32(FallbackCodepoint) + " b").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.Equal(2, results.Count);
// The fallback run covers the Hebrew letter only. Before the fix it was 2 characters
// long: the space was pulled into the fallback run and rendered with its metrics.
Assert.Equal(1, results[0].Length);
Assert.Equal(fallbackTypeface, results[0].Properties!.Typeface);
// The space returns to the primary along with the rest of the text.
Assert.Equal(2, results[1].Length);
Assert.Equal(defaultProperties.Typeface, results[1].Properties!.Typeface);
}
finally
{
FormattingObjectPool.RentedList<TextRun>? toReturn = results;
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
}
}
}
// The user-visible half of the same defect: the absorbed space is measured with the fallback
// font, so a space typed after an emoji has a different advance than the same space elsewhere
// in the line - a full em with the platform emoji fonts, and a narrower space with the emoji
// font bundled here. Either way it is not the primary's.
// https://github.com/AvaloniaUI/Avalonia/issues/14011
[Fact]
public void FormatLine_Keeps_A_Space_After_A_Fallback_Run_At_The_Primary_Width()
{
using (Start(PrimaryFont, EmojiFont))
{
var fontManager = FontManager.Current;
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
var defaultGlyphTypeface = defaultProperties.CachedGlyphTypeface;
Assert.False(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(EmojiCodepoint, out _));
Assert.True(fontManager.TryMatchCharacter(EmojiCodepoint, FontStyle.Normal, FontWeight.Normal,
FontStretch.Normal, defaultProperties.Typeface.FontFamily, null, out var emojiTypeface));
Assert.True(fontManager.TryGetGlyphTypeface(emojiTypeface, out var emojiGlyphTypeface));
// The whole point of the test: the two fonts disagree about how wide a space is, so
// whichever font shapes it is directly observable in the line width.
Assert.NotEqual(SpaceAdvanceInEm(defaultGlyphTypeface), SpaceAdvanceInEm(emojiGlyphTypeface), 3);
var formatter = new TextFormatterImpl();
double Width(string text)
{
var textLine = formatter.FormatLine(new SingleBufferTextSource(text, defaultProperties), 0,
double.PositiveInfinity, new GenericTextParagraphProperties(defaultProperties));
Assert.NotNull(textLine);
return textLine.WidthIncludingTrailingWhitespace;
}
var emoji = char.ConvertFromUtf32(EmojiCodepoint);
// Isolate the space by differencing, so the surrounding glyphs' advances cancel out.
var plainSpace = Width("a b") - Width("ab");
var spaceAfterFallback = Width(emoji + " b") - Width(emoji + "b");
Assert.Equal(plainSpace, spaceAfterFallback, 3);
}
}
private static double SpaceAdvanceInEm(GlyphTypeface glyphTypeface)
{
Assert.True(glyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out var glyph));
Assert.True(glyphTypeface.TryGetHorizontalGlyphAdvance(glyph, out var advance));
return (double)advance / glyphTypeface.Metrics.DesignEmHeight;
}
// The previous run's font is reused as an anti-thrashing bias. A space belongs to the primary
// font, so it forms a run of its own between two fallback words - and that run must not become
// the bias, or each word re-runs the fallback search and the two can land on different fonts.
[Fact]
public void GetShapeableCharacters_Keeps_The_Previous_Fallback_Across_A_Space()
{
using (Start(PrimaryFont, NotoSansScFont, NotoSansJpFont))
{
var fontManager = FontManager.Current;
var defaultProperties = new GenericTextRunProperties(Typeface.Default);
// The previous run resolved to the Simplified-Chinese font.
var scTypeface = new Typeface(new FontFamily("fonts:SystemFonts#Noto Sans SC"));
Assert.True(fontManager.TryGetGlyphTypeface(scTypeface, out var scGlyphTypeface));
const int han = 0x4E2D; // 中, covered by both regional fonts.
// Preconditions: the primary covers the space but not the ideograph, the previous font
// covers the ideograph, and a fresh search for it would pick the *other* font - so the
// font of the second run tells us whether the bias survived the space.
Assert.True(defaultProperties.CachedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
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, null, out var freshMatch));
Assert.True(fontManager.TryGetGlyphTypeface(freshMatch, out var freshGlyphTypeface));
Assert.Equal("Noto Sans JP", freshGlyphTypeface.FamilyName);
var text = (" " + char.ConvertFromUtf32(han)).AsMemory();
var textCharacters = new TextCharacters(text, defaultProperties);
var results = FormattingObjectPool.Instance.TextRunLists.Rent();
try
{
TextRunProperties? previousProperties = new GenericTextRunProperties(scTypeface);
textCharacters.GetShapeableCharacters(text, 0, fontManager, ref previousProperties, results);
Assert.Equal(2, results.Count);
Assert.Equal(1, results[0].Length);
Assert.Equal(defaultProperties.Typeface, results[0].Properties!.Typeface);
Assert.True(fontManager.TryGetGlyphTypeface(results[1].Properties!.Typeface, out var runGlyphTypeface));
Assert.Equal("Noto Sans SC", runGlyphTypeface.FamilyName);
}
finally
{
FormattingObjectPool.RentedList<TextRun>? toReturn = results;
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn);
}
}
}
// Only spacing whitespace (Zs) returns to the default typeface. Codepoint.IsWhiteSpace also
// covers control and format codepoints - including the default-ignorable bidi controls, which
// many fonts map. A default typeface that cannot shape the script must not pull a
// right-to-left mark out of the fallback run just because its cmap has it: the mark renders
// nothing either way, and splitting there cuts the run for no reason.
[Fact]
public void TryGetShapeableLength_Does_Not_Reclaim_A_Bidi_Control_As_Whitespace()
{
using (Start(PrimaryFont, FallbackFont))
{
// DejaVu Sans plays the default: its cmap has the Arabic letter, the right-to-left
// mark and the space, but the test probes the tier where it cannot shape Arabic.
// Cascadia Code plays the probed fallback; it has the letter and needs no glyph for
// the default-ignorable mark.
var defaultGlyphTypeface = new Typeface(FontFamily.Parse(
"resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#DejaVu Sans")).GlyphTypeface;
var probedGlyphTypeface = new Typeface(FontFamily.Parse(
"resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#Cascadia Code")).GlyphTypeface;
const int alef = 0x0627;
const int rightToLeftMark = 0x200F;
Assert.True(probedGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(alef, out _));
Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(rightToLeftMark, out _));
Assert.True(defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(' ', out _));
// Letter, mark, letter, then a space: the mark stays inside the fallback run, the
// space still returns to the default.
var text = "ا‏ا z";
Assert.True(TextCharacters.TryGetShapeableLength(text.AsSpan(), probedGlyphTypeface,
defaultGlyphTypeface, defaultCanShapeScript: false, requireFullCluster: true,
out var length));
Assert.Equal(3, length);
}
}
// 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<IFontManagerImpl>().ToConstant(fontManagerImpl);
var fontManager = new FontManager(fontManagerImpl);
AvaloniaLocator.CurrentMutable
.Bind<FontManager>().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;
}
}
}