diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs
index 50e1fde5..7f80a15d 100644
--- a/src/Filee.App/Program.cs
+++ b/src/Filee.App/Program.cs
@@ -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;
@@ -302,8 +305,55 @@ public static AppBuilder BuildAvaloniaApp()
}
}
-/// Embedded Noto fonts: fonts:Filee#Noto Sans, #Noto Sans JP, #Noto Sans KR,
-/// #Noto Sans SC, #Noto Sans Devanagari, #Noto Sans Arabic, #Noto Sans Thai.
-internal sealed class FileeFontCollection() : Avalonia.Media.Fonts.EmbeddedFontCollection(
- new Uri("fonts:Filee", UriKind.Absolute),
- new Uri("avares://Filee/Assets/Fonts", UriKind.Absolute));
+///
+/// Embedded Noto fonts: fonts:Filee#Noto Sans, #Noto Sans JP, #Noto Sans KR,
+/// #Noto Sans SC, #Noto Sans Devanagari, #Noto Sans Arabic, #Noto Sans Thai.
+///
+/// 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.
+///
+///
+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? 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 GetEnumerator() => _fonts.GetEnumerator();
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ public void Dispose() => ((IDisposable)_fonts).Dispose();
+}
diff --git a/src/Filee.App/Services/SoftPalette.cs b/src/Filee.App/Services/SoftPalette.cs
index 79f41ba1..6619e85d 100644
--- a/src/Filee.App/Services/SoftPalette.cs
+++ b/src/Filee.App/Services/SoftPalette.cs
@@ -33,9 +33,10 @@ private static readonly (string Embedded, string System) Chinese =
///
/// 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.
///
- 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";
/// All resource keys written by ApplyAccent that must be cleared from top level when per-variant dictionaries are used.
public static readonly string[] AccentResourceKeys =
diff --git a/tests/Filee.App.Tests/LocalizationResourceTests.cs b/tests/Filee.App.Tests/LocalizationResourceTests.cs
index 9b555741..0d4c811d 100644
--- a/tests/Filee.App.Tests/LocalizationResourceTests.cs
+++ b/tests/Filee.App.Tests/LocalizationResourceTests.cs
@@ -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;
@@ -30,6 +32,77 @@ public void Arabic_font_chain_draws_trimming_ellipsis_and_matches_arabic_letters
finally { TestServices.EnsureInitialized("en"); }
}
+ /// What a piece of text is drawn in, run by run, as a TextBlock lays it out (it passes no culture).
+ 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()
+ .Select(r => (r.Text.ToString(), r.GlyphRun.GlyphTypeface.FamilyName))];
+ }
+
+ ///
+ /// #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.
+ ///
+ [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"); }
+ }
+
+ /// #1095's strings: 跟随系 stayed Japanese up to 统, the first character Noto Sans JP doesn't have.
+ [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"); }
+ }
+
+ ///
+ /// #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).
+ ///
+ [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()
{