csharpc-sharpdotnetxamlavaloniauicross-platformcross-platform-xamlavaloniaguimulti-platformuser-interfacedotnetcore
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
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;
|
|
}
|
|
}
|
|
}
|
|
|