|
|
|
@ -31,6 +31,13 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|
|
|
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; |
|
|
|
@ -329,6 +336,215 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 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 = |
|
|
|
|