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
60 changes: 55 additions & 5 deletions src/Filee.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit).

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Fonts;
using Filee.App.Services;
using Filee.Core.Platform;
using Filee.Core.Settings;
Expand Down Expand Up @@ -302,8 +305,55 @@ public static AppBuilder BuildAvaloniaApp()
}
}

/// <summary>Embedded Noto fonts: <c>fonts:Filee#Noto Sans</c>, <c>#Noto Sans JP</c>, <c>#Noto Sans KR</c>,
/// <c>#Noto Sans SC</c>, <c>#Noto Sans Devanagari</c>, <c>#Noto Sans Arabic</c>, <c>#Noto Sans Thai</c>.</summary>
internal sealed class FileeFontCollection() : Avalonia.Media.Fonts.EmbeddedFontCollection(
new Uri("fonts:Filee", UriKind.Absolute),
new Uri("avares://Filee/Assets/Fonts", UriKind.Absolute));
/// <summary>
/// Embedded Noto fonts: <c>fonts:Filee#Noto Sans</c>, <c>#Noto Sans JP</c>, <c>#Noto Sans KR</c>,
/// <c>#Noto Sans SC</c>, <c>#Noto Sans Devanagari</c>, <c>#Noto Sans Arabic</c>, <c>#Noto Sans Thai</c>.
/// <para>
/// A letter is only matched in the family asked for, so the order of SoftPalette's font chain decides (#1095). Avalonia's
/// own collection, asked for Noto Sans about a Han character, answers with the first family of its own that has it,
/// Noto Sans JP, before the font manager reaches the chain's next family: every language drew Han in Japanese shapes,
/// up to the first character that font lacks. Text has no culture to go by either, as TextBlock passes none. It takes
/// this wrapper rather than a subclass: the font manager skips the virtual method on Avalonia's collections.
/// </para>
/// </summary>
internal sealed class FileeFontCollection : IFontCollection
{
private readonly EmbeddedFontCollection _fonts = new(
new Uri("fonts:Filee", UriKind.Absolute),
new Uri("avares://Filee/Assets/Fonts", UriKind.Absolute));

public Uri Key => _fonts.Key;
public int Count => _fonts.Count;
public FontFamily this[int index] => _fonts[index];

public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch,
string? familyName, CultureInfo? culture, out Typeface typeface)
{
typeface = default;
// No culture from here on: the family is settled, and one would send it looking elsewhere again.
return familyName is not null
&& _fonts.TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face)
&& face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _)
&& _fonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, null, out typeface);
}

public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface) =>
_fonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface);

public bool TryGetFamilyTypefaces(string familyName,
[NotNullWhen(true)] out IReadOnlyList<Typeface>? familyTypefaces) =>
_fonts.TryGetFamilyTypefaces(familyName, out familyTypefaces);

public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) =>
_fonts.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface);

public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface) =>
_fonts.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface);

public IEnumerator<FontFamily> GetEnumerator() => _fonts.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() => ((IDisposable)_fonts).Dispose();
}
5 changes: 3 additions & 2 deletions src/Filee.App/Services/SoftPalette.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ private static readonly (string Embedded, string System) Chinese =

/// <summary>
/// Traditional Chinese has no embedded font (two more Noto CJK files would add 11 MB): Windows and macOS always carry
/// these. Listed before the embedded Noto Sans SC, whose Han shapes differ, so zh-TW text takes Traditional shapes.
/// their two, Linux often has the Noto one. Listed before the embedded Noto Sans SC, whose Han shapes differ, so zh-TW
/// text takes Traditional shapes.
/// </summary>
private const string TraditionalChineseSystem = "Microsoft JhengHei UI, Microsoft JhengHei, PingFang TC";
private const string TraditionalChineseSystem = "Microsoft JhengHei UI, Microsoft JhengHei, PingFang TC, Noto Sans CJK TC";

/// <summary>All resource keys written by ApplyAccent that must be cleared from top level when per-variant dictionaries are used.</summary>
public static readonly string[] AccentResourceKeys =
Expand Down
73 changes: 73 additions & 0 deletions tests/Filee.App.Tests/LocalizationResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Platform;
using Avalonia.VisualTree;
using Filee.App.Controls;
using Filee.App.Services;
Expand Down Expand Up @@ -30,6 +32,77 @@ public void Arabic_font_chain_draws_trimming_ellipsis_and_matches_arabic_letters
finally { TestServices.EnsureInitialized("en"); }
}

/// <summary>What a piece of text is drawn in, run by run, as a TextBlock lays it out (it passes no culture).</summary>
private static List<(string Text, string Family)> FontsDrawing(string text, string language, FontWeight weight = FontWeight.Normal)
{
var family = FontFamily.Parse(ThemeService.FontFamilies(language));
using var layout = new TextLayout(text, new Typeface(family, FontStyle.Normal, weight), 14, Brushes.Black);
return [.. layout.TextLines.SelectMany(l => l.TextRuns).OfType<ShapedTextRun>()
.Select(r => (r.Text.ToString(), r.GlyphRun.GlyphTypeface.FamilyName))];
}

/// <summary>
/// #1095: these Han characters differ between the regions and every embedded CJK font has them, so only the font
/// chain's order can pick the font. Before, it was Noto Sans JP in every language.
/// </summary>
[AvaloniaTheory]
[InlineData("zh-CN", "Noto Sans SC")]
[InlineData("ja", "Noto Sans JP")]
[InlineData("ko", "Noto Sans KR")]
[InlineData("en", "Noto Sans KR")]
public void Han_characters_take_the_embedded_font_of_the_ui_languages_region(string language, string font)
{
Assert.SkipUnless(AssetLoader.Exists(new Uri("avares://Filee/Assets/Fonts/NotoSansSC-Regular.otf")), "Needs build/fetch-fonts.ps1.");
TestServices.EnsureInitialized(language);
try
{
var family = FontFamily.Parse(ThemeService.FontFamilies(language));
foreach (var character in "将系与所")
{
Assert.True(FontManager.Current.TryMatchCharacter(character, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, family, null, out var typeface));
Assert.Equal(font, typeface.GlyphTypeface.FamilyName);
}
foreach (var weight in new[] { FontWeight.Normal, FontWeight.SemiBold, FontWeight.Bold })
Assert.Equal([("Save ", "Noto Sans"), ("将系与所", font)],
FontsDrawing("Save 将系与所", language, weight));
}
finally { TestServices.EnsureInitialized("en"); }
}

/// <summary>#1095's strings: 跟随系 stayed Japanese up to 统, the first character Noto Sans JP doesn't have.</summary>
[AvaloniaTheory]
[InlineData("将文件拖放到这里")]
[InlineData("跟随系统")]
[InlineData("与源文件相同的文件夹")]
[InlineData("所有转换都保留原文件日期")]
public void Simplified_chinese_text_is_one_run_of_noto_sans_sc(string text)
{
Assert.SkipUnless(AssetLoader.Exists(new Uri("avares://Filee/Assets/Fonts/NotoSansSC-Regular.otf")), "Needs build/fetch-fonts.ps1.");
TestServices.EnsureInitialized("zh-CN");
try
{
Assert.Equal([(text, "Noto Sans SC")], FontsDrawing(text, "zh-CN"));
}
finally { TestServices.EnsureInitialized("en"); }
}

/// <summary>
/// #1095: the only embedded font zh-TW may land on is Noto Sans SC, and that only on a machine with no Traditional
/// one (macOS answers PingFang TC here, Windows Microsoft JhengHei UI).
/// </summary>
[AvaloniaFact]
public void Traditional_chinese_text_reaches_the_system_traditional_font()
{
Assert.SkipUnless(AssetLoader.Exists(new Uri("avares://Filee/Assets/Fonts/NotoSansSC-Regular.otf")), "Needs build/fetch-fonts.ps1.");
TestServices.EnsureInitialized("zh-TW");
try
{
string[] allowed = ["Microsoft JhengHei UI", "Microsoft JhengHei", "PingFang TC", "Noto Sans CJK TC", "Noto Sans SC"];
Assert.All(FontsDrawing("將文件體", "zh-TW"), run => Assert.Contains(run.Family, allowed));
}
finally { TestServices.EnsureInitialized("en"); }
}

[AvaloniaFact]
public void Arabic_mirrors_the_main_window_and_english_brings_it_back()
{
Expand Down
Loading