From c1aa471bbcb3d15813c2bd4c8136f52d66bf2e1a Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 4 Nov 2026 22:31:02 +0900 Subject: [PATCH 1/2] Draw Han in the UI language's own font Avalonia's embedded font collection, asked for a character its requested family lacks, answers with the first family of its own that has it. A composite family's first entry, Noto Sans, has no Han, so that answer was always Noto Sans JP and the chain's next entries were never reached: every language drew Han in Japanese shapes up to the first character the font lacks, which is why only some characters in a line looked wrong. FileeFontCollection now matches a letter only in the family asked for, so the order of SoftPalette's chain decides. zh-TW reaches the system's Traditional fonts this way, and Linux gets Noto Sans CJK TC in that list. Fixes #1095 Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Program.cs | 60 ++++++++++++++-- src/Filee.App/Services/SoftPalette.cs | 5 +- .../LocalizationResourceTests.cs | 69 +++++++++++++++++++ 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 50e1fde59..7f80a15d9 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 79f41ba19..6619e85d7 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 9b5557410..5d33fc0bc 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,73 @@ 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: no embedded font has Traditional shapes, so none of them may step in before the system's. + [AvaloniaFact] + public void Traditional_chinese_text_reaches_the_system_traditional_font() + { + Assert.SkipUnless(FontManager.Current.SystemFonts.Any(f => f.Name == "Microsoft JhengHei UI"), "Needs a Windows font."); + TestServices.EnsureInitialized("zh-TW"); + try + { + Assert.Equal([("將文件體", "Microsoft JhengHei UI")], FontsDrawing("將文件體", "zh-TW")); + } + finally { TestServices.EnsureInitialized("en"); } + } + [AvaloniaFact] public void Arabic_mirrors_the_main_window_and_english_brings_it_back() { From 6cdad6587b6e85f67800a0b5398f4dc3ac6e86b8 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 4 Nov 2026 23:59:15 +0900 Subject: [PATCH 2/2] Accept each platform's Traditional font in the zh-TW test macOS answers PingFang TC, which is right. The test only knew Windows' font and didn't skip there, as a system collection also lists the names it was asked for. Co-Authored-By: Claude Opus 5.5 --- tests/Filee.App.Tests/LocalizationResourceTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Filee.App.Tests/LocalizationResourceTests.cs b/tests/Filee.App.Tests/LocalizationResourceTests.cs index 5d33fc0bc..0d4c811d4 100644 --- a/tests/Filee.App.Tests/LocalizationResourceTests.cs +++ b/tests/Filee.App.Tests/LocalizationResourceTests.cs @@ -86,15 +86,19 @@ public void Simplified_chinese_text_is_one_run_of_noto_sans_sc(string text) finally { TestServices.EnsureInitialized("en"); } } - /// #1095: no embedded font has Traditional shapes, so none of them may step in before the system's. + /// + /// #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(FontManager.Current.SystemFonts.Any(f => f.Name == "Microsoft JhengHei UI"), "Needs a Windows font."); + Assert.SkipUnless(AssetLoader.Exists(new Uri("avares://Filee/Assets/Fonts/NotoSansSC-Regular.otf")), "Needs build/fetch-fonts.ps1."); TestServices.EnsureInitialized("zh-TW"); try { - Assert.Equal([("將文件體", "Microsoft JhengHei UI")], FontsDrawing("將文件體", "zh-TW")); + 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"); } }