Browse Source
* Add GlyphTypeface.CanShapeScript(Script), backed by a lazy read of the GSUB/GPOS ScriptList via the new ScriptListTable parser. Add FontFallbackScriptHints.TryGetComplexShapingTags, which maps 17 complex scripts to the OpenType script tag(s) a font must declare to shape them. Thread a Script shapingScript constraint through FontManager.TryMatchCharacter and FontCollectionBase.TryMatchCharacter as internal overloads, leaving the public signatures untouched. Rework TextCharacters.CreateShapeableRun into a capability preference: a complex script tries shaping-capable fonts first (tier 0), then cmap-only coverage (tier 1); simple scripts keep the single historical tier. Replace the NUL placeholder U+200B (ZERO WIDTH SPACE) with U+2060 (WORD JOINER) so the substitution introduces no line-break opportunity. Suppress previous-run font reuse across a culture change for locale-sensitive scripts, so a zh run's font no longer pins following ja text. Add 3 subset test fonts and 3 new test files (7 new tests) and fix 2 stale doc-comment crefs. * Fix comments * Fix font fallback reusing the wrong cached face from the script bucket FontCollectionBase.TryMatchCharacter caches a resolved fallback family per (script, culture) bucket. Two bugs let that bucket hand back a face that does not match the request: - A positive bucket entry suppressed the Tier D platform lookup for any later codepoint, even one the bucket family cannot cover. A CJK run left the Simplified-only U+534E unmatched (tofu) because the bucket resolved to a Japanese font that lacks it, while another installed font could place it. Tier D now treats only a negative (null) entry as "platform already attempted"; a positive entry no longer blocks the platform, since that family may not cover this codepoint. - A cached covering match of a neighbouring FontCollectionKey was reused for a different requested key. After a Bold run cached a face, a following Normal run reused it and rendered bold (and the symmetric style and stretch cases). Tier A, B and C now keep the exact key: when the cached covering match is a neighbouring key, the exact-key face of the same family is resolved from the platform via TryMatchCharacterFromPlatform, falling back to the neighbour only when the platform has no exact face. Collections without a platform are unchanged. Add regression tests for both: a bucket whose font lacks a later same-script codepoint, and weight and style leaks resolved through Tier B (script cache) and Tier C (the sweep, exercised via a non-locale-sensitive script with no culture).pull/21693/head
committed by
GitHub
14 changed files with 1314 additions and 70 deletions
@ -0,0 +1,73 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Media.Fonts.Tables |
|||
{ |
|||
/// <summary>
|
|||
/// Reads the script tags declared in a font's OpenType Layout (<c>GSUB</c>/<c>GPOS</c>)
|
|||
/// ScriptList. A declared script tag signals that the font carries shaping rules for that
|
|||
/// script — the signal used to decide whether a font can actually <em>shape</em> a complex
|
|||
/// script rather than merely map its codepoints through cmap.
|
|||
/// <see href="https://learn.microsoft.com/typography/opentype/spec/chapter2#script-list-table-and-script-record"/>
|
|||
/// </summary>
|
|||
internal static class ScriptListTable |
|||
{ |
|||
private static readonly OpenTypeTag s_gsub = OpenTypeTag.Parse("GSUB"); |
|||
private static readonly OpenTypeTag s_gpos = OpenTypeTag.Parse("GPOS"); |
|||
|
|||
/// <summary>
|
|||
/// Adds the GSUB and GPOS script tags declared by <paramref name="glyphTypeface"/> to
|
|||
/// <paramref name="scriptTags"/>.
|
|||
/// </summary>
|
|||
/// <returns>
|
|||
/// <c>false</c> if a layout table is present but could not be parsed — the caller should
|
|||
/// then treat shaping capability as unknown rather than unsupported (don't reject the font
|
|||
/// on the strength of an empty set). An absent table is not a failure.
|
|||
/// </returns>
|
|||
public static bool TryReadScriptTags(GlyphTypeface glyphTypeface, HashSet<OpenTypeTag> scriptTags) |
|||
{ |
|||
// Non-short-circuiting '&' so both tables are always attempted.
|
|||
return TryReadScriptList(glyphTypeface, s_gsub, scriptTags) |
|||
& TryReadScriptList(glyphTypeface, s_gpos, scriptTags); |
|||
} |
|||
|
|||
private static bool TryReadScriptList(GlyphTypeface glyphTypeface, OpenTypeTag tableTag, |
|||
HashSet<OpenTypeTag> scriptTags) |
|||
{ |
|||
if (!glyphTypeface.PlatformTypeface.TryGetTable(tableTag, out var table)) |
|||
{ |
|||
// An absent table simply contributes no scripts
|
|||
return true; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var reader = new BigEndianBinaryReader(table.Span); |
|||
|
|||
// GSUB/GPOS header: majorVersion, minorVersion, scriptListOffset, featureListOffset,
|
|||
// lookupListOffset.
|
|||
reader.ReadUInt16(); |
|||
reader.ReadUInt16(); |
|||
var scriptListOffset = reader.ReadOffset16(); |
|||
|
|||
// ScriptList: scriptCount, then scriptCount ScriptRecords of (Tag, Offset16).
|
|||
reader.Seek(scriptListOffset); |
|||
|
|||
var scriptCount = reader.ReadUInt16(); |
|||
|
|||
for (var i = 0; i < scriptCount; i++) |
|||
{ |
|||
scriptTags.Add(new OpenTypeTag(reader.ReadUInt32())); |
|||
reader.ReadOffset16(); // scriptOffset — not needed
|
|||
} |
|||
|
|||
return true; |
|||
} |
|||
catch (Exception) |
|||
{ |
|||
// Malformed layout table — capability unknown.
|
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,91 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.Fonts; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.Platform; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media |
|||
{ |
|||
public class GlyphTypefaceShapingTests |
|||
{ |
|||
private const string MonoFont = "Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf"; |
|||
private const string ArabicFont = "Avalonia.Skia.UnitTests.Assets.NotoSansArabic-Regular.ttf"; |
|||
|
|||
// A tiny Noto Sans Arabic subset with the GSUB/GPOS layout tables stripped: it keeps Arabic
|
|||
// cmap glyphs but cannot shape Arabic. Renamed so it doesn't collide with the full font.
|
|||
private const string ArabicNoLayoutFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansArabic-NoLayout.ttf"; |
|||
|
|||
// P0 — CanShapeScript gates complex scripts on GSUB/GPOS script coverage, not cmap. This is
|
|||
// the primitive the F3 capability fallback (Strategy A) builds on.
|
|||
[Fact] |
|||
public void CanShapeScript_Gates_Complex_Scripts_On_Layout_Coverage_Not_Cmap() |
|||
{ |
|||
using (Start(MonoFont, ArabicFont, ArabicNoLayoutFont)) |
|||
{ |
|||
var fontManager = FontManager.Current; |
|||
|
|||
Assert.True(fontManager.TryGetGlyphTypeface( |
|||
new Typeface("fonts:SystemFonts#Noto Mono"), out var mono)); |
|||
Assert.True(fontManager.TryGetGlyphTypeface( |
|||
new Typeface("fonts:SystemFonts#Noto Sans Arabic"), out var arabic)); |
|||
Assert.True(fontManager.TryGetGlyphTypeface( |
|||
new Typeface("fonts:SystemFonts#Noto Sans Arabic NoLayout"), out var arabicNoLayout)); |
|||
|
|||
// Simple scripts never require layout tables — always true, regardless of the font.
|
|||
Assert.True(mono.CanShapeScript(Script.Latin)); |
|||
Assert.True(arabic.CanShapeScript(Script.Latin)); |
|||
|
|||
// The real Arabic font declares the 'arab' GSUB script, so it can shape Arabic.
|
|||
Assert.True(arabic.CanShapeScript(Script.Arabic)); |
|||
|
|||
// A Latin-only font has no Arabic layout coverage.
|
|||
Assert.False(mono.CanShapeScript(Script.Arabic)); |
|||
|
|||
// The crux: the stripped font HAS Arabic cmap glyphs but no GSUB/GPOS, so it cannot
|
|||
// shape Arabic even though TryGetGlyph succeeds. cmap coverage is not shaping capability.
|
|||
Assert.True(arabicNoLayout.CharacterToGlyphMap.TryGetGlyph(0x0627, out _)); // ا is mapped
|
|||
Assert.False(arabicNoLayout.CanShapeScript(Script.Arabic)); |
|||
|
|||
// A complex script the Arabic font does not declare is rejected too.
|
|||
Assert.False(arabic.CanShapeScript(Script.Devanagari)); |
|||
} |
|||
} |
|||
|
|||
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); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,110 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
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 ShapingCapabilityFallbackTests |
|||
{ |
|||
private const string MonoFont = "Avalonia.Skia.UnitTests.Assets.NotoMono-Regular.ttf"; |
|||
private const string ArabicFont = "Avalonia.Skia.UnitTests.Assets.NotoSansArabic-Regular.ttf"; |
|||
|
|||
// Tiny Noto Sans Arabic subset with the layout tables stripped: it has the Arabic cmap glyphs
|
|||
// but no GSUB/GPOS, so it cannot actually shape Arabic. Renamed so it doesn't collide with the
|
|||
// full font.
|
|||
private const string ArabicNoLayoutFont = "Avalonia.Skia.UnitTests.Fonts.NotoSansArabic-NoLayout.ttf"; |
|||
|
|||
// For a complex script, a primary that has the cmap glyphs but can't shape it (no GSUB/GPOS) is
|
|||
// upgraded to a shaping-capable font. This is unconditional — there is no longer a mode toggle.
|
|||
[Fact] |
|||
public void CmapOnly_Complex_Script_Primary_Is_Upgraded_To_A_Shaping_Capable_Font() |
|||
{ |
|||
// A shaping-capable Arabic font is present, so the cmap-only primary is replaced by it.
|
|||
Assert.Equal("Noto Sans Arabic", |
|||
ResolveArabicRunFamily(MonoFont, ArabicFont, ArabicNoLayoutFont)); |
|||
} |
|||
|
|||
// When no shaping-capable font for the script exists, the cmap-only font is kept (the capability
|
|||
// tier finds nothing, the cmap tier then accepts it) — we never reject more than before.
|
|||
[Fact] |
|||
public void CmapOnly_Complex_Script_Primary_Is_Kept_When_No_Capable_Font_Exists() |
|||
{ |
|||
Assert.Equal("Noto Sans Arabic NoLayout", |
|||
ResolveArabicRunFamily(MonoFont, ArabicNoLayoutFont)); |
|||
} |
|||
|
|||
private static string ResolveArabicRunFamily(params string[] fontResourceNames) |
|||
{ |
|||
using (Start(fontResourceNames)) |
|||
{ |
|||
var fontManager = FontManager.Current; |
|||
|
|||
// Primary run typeface: the cmap-only (no-layout) Arabic font.
|
|||
var defaultProperties = new GenericTextRunProperties( |
|||
new Typeface("fonts:SystemFonts#Noto Sans Arabic NoLayout")); |
|||
|
|||
var text = char.ConvertFromUtf32(0x0627).AsMemory(); // U+0627 ARABIC LETTER ALEF
|
|||
|
|||
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.True(fontManager.TryGetGlyphTypeface(results[0].Properties!.Typeface, out var runGlyphTypeface)); |
|||
|
|||
return runGlyphTypeface.FamilyName; |
|||
} |
|||
finally |
|||
{ |
|||
FormattingObjectPool.RentedList<TextRun>? toReturn = results; |
|||
FormattingObjectPool.Instance.TextRunLists.Return(ref toReturn); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static IDisposable Start(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); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,308 @@ |
|||
#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"; |
|||
|
|||
// 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"; |
|||
|
|||
// 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 _)); |
|||
|
|||
// Probe for a combining mark the primary lacks but a fallback covers together with the
|
|||
// base. Probing keeps the test robust to the exact coverage of the embedded fonts.
|
|||
var mark = 0; |
|||
|
|||
foreach (var candidate in CombiningMarkCandidates) |
|||
{ |
|||
if (defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(candidate, out _)) |
|||
{ |
|||
continue; // primary already covers it - not a useful probe
|
|||
} |
|||
|
|||
if (fontManager.TryMatchCharacter(candidate, FontStyle.Normal, FontWeight.Normal, |
|||
FontStretch.Normal, defaultFontFamily, null, out var markTypeface) |
|||
&& fontManager.TryGetGlyphTypeface(markTypeface, out var markGlyphTypeface) |
|||
&& markGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(baseCodepoint, out _)) |
|||
{ |
|||
mark = candidate; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
Assert.True(mark != 0, |
|||
"No combining mark found that the primary font lacks but a fallback covers together with the base."); |
|||
|
|||
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); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 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 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; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue