From 7c781506edd75c787169019b5a77d56e9963cdab Mon Sep 17 00:00:00 2001 From: Saravanan Ganapathi Date: Tue, 11 Nov 2025 13:25:15 +0530 Subject: [PATCH 1/2] Feature: Add font file thumbnail generation and display font names --- .../Storage/Helpers/FileThumbnailHelper.cs | 20 ++ .../Utils/Storage/Helpers/FontFileHelper.cs | 281 ++++++++++++++++++ src/Files.App/ViewModels/ShellViewModel.cs | 15 + 3 files changed, 316 insertions(+) create mode 100644 src/Files.App/Utils/Storage/Helpers/FontFileHelper.cs diff --git a/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs b/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs index fb4f292bdf14..5c132503c0fc 100644 --- a/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs +++ b/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs @@ -1,6 +1,8 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using Files.Shared.Helpers; +using System.IO; using Windows.Storage.FileProperties; namespace Files.App.Utils.Storage @@ -16,6 +18,24 @@ public static class FileThumbnailHelper // Ensure size is at least 1 to prevent layout errors size = Math.Max(1, size); + if (!isFolder && !iconOptions.HasFlag(IconOptions.ReturnIconOnly)) + { + var extension = Path.GetExtension(path); + if (FileExtensionHelpers.IsFontFile(extension)) + { + var winrtThumbnail = await FontFileHelper.GetWinRTThumbnailAsync(path, (uint)size); + if (winrtThumbnail is not null) + return winrtThumbnail; + + if (!extension.Equals(".fon", StringComparison.OrdinalIgnoreCase)) + { + var fontThumbnail = await Win32Helper.StartSTATask(() => FontFileHelper.GenerateFontThumbnail(path, (int)size)); + if (fontThumbnail is not null) + return fontThumbnail; + } + } + } + return await Win32Helper.StartSTATask(() => Win32Helper.GetIcon(path, (int)size, isFolder, iconOptions)); } diff --git a/src/Files.App/Utils/Storage/Helpers/FontFileHelper.cs b/src/Files.App/Utils/Storage/Helpers/FontFileHelper.cs new file mode 100644 index 000000000000..6e056d0924fe --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/FontFileHelper.cs @@ -0,0 +1,281 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Graphics.Canvas.Text; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.IO; +using System.Text; +using Windows.Storage; +using Windows.Storage.FileProperties; + +namespace Files.App.Utils.Storage +{ + public static class FontFileHelper + { + private const float FontSizeRatio = 0.35f; + private const string PreviewText = "Abg"; + + public static async Task GetWinRTThumbnailAsync(string fontPath, uint size) + { + StorageFile? file = null; + StorageItemThumbnail? thumbnail = null; + try + { + file = await StorageFile.GetFileFromPathAsync(fontPath); + thumbnail = await file.GetThumbnailAsync(ThumbnailMode.SingleItem, size); + + if (thumbnail is null || thumbnail.Size == 0) + { + return null; + } + + using (var stream = thumbnail.AsStream()) + { + using var memoryStream = new MemoryStream((int)thumbnail.Size); + await stream.CopyToAsync(memoryStream); + + return memoryStream.ToArray(); + } + } + catch (Exception ex) + { + App.Logger.LogError(ex, $"Exception while getting WinRT thumbnail for {fontPath}."); + return null; + } + finally + { + thumbnail?.Dispose(); + } + } + + public static byte[]? GenerateFontThumbnail(string fontPath, int size) + { + try + { + if (!File.Exists(fontPath)) + return null; + + using var fontCollection = new PrivateFontCollection(); + fontCollection.AddFontFile(fontPath); + + if (fontCollection.Families.Length == 0) + return null; + + var fontFamily = fontCollection.Families[0]; + var style = GetAvailableFontStyle(fontFamily); + + using var bitmap = new Bitmap(size, size); + using var graphics = Graphics.FromImage(bitmap); + + graphics.Clear(Color.White); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.TextRenderingHint = TextRenderingHint.AntiAlias; + + var fontSize = size * FontSizeRatio; + using var font = new Font(fontFamily, fontSize, style, GraphicsUnit.Pixel); + + var textSize = graphics.MeasureString(PreviewText, font); + + var x = (size - textSize.Width) / 2; + var y = (size - textSize.Height) / 2; + + using var brush = new SolidBrush(Color.Black); + graphics.DrawString(PreviewText, font, brush, x, y); + + using var ms = new MemoryStream(); + bitmap.Save(ms, ImageFormat.Png); + return ms.ToArray(); + } + catch (Exception ex) + { + App.Logger.LogError(ex, $"Exception while generating font thumbnail for {fontPath}."); + return null; + } + } + + public static string? GetFontName(string fontPath) + { + try + { + if (!File.Exists(fontPath)) + return null; + + var fullName = ExtractFontNameFromTable(fontPath); + if (!string.IsNullOrEmpty(fullName)) + return fullName; + + using var fontCollection = new PrivateFontCollection(); + fontCollection.AddFontFile(fontPath); + + if (fontCollection.Families.Length == 0) + return null; + + return fontCollection.Families[0].Name; + } + catch + { + App.Logger.LogError($"Failed to get font name for file: {fontPath}"); + return null; + } + } + + private static string? ExtractFontNameFromTable(string fontPath) + { + try + { + using var fileStream = File.OpenRead(fontPath); + using var reader = new BinaryReader(fileStream); + + // Read TTF header to find table directory + var sfntVersion = ReadUInt32BigEndian(reader); + + // Check if it's a TrueType Collection (.ttc) + if (sfntVersion == 0x74746366) // 'ttcf' + { + // For TTC files, read the first font in the collection + reader.ReadUInt32(); // version + var numFonts = ReadUInt32BigEndian(reader); + if (numFonts == 0) + return null; + + // Read offset to first font + var firstFontOffset = ReadUInt32BigEndian(reader); + fileStream.Seek(firstFontOffset, SeekOrigin.Begin); + reader.ReadUInt32(); // Skip sfntVersion of inner font + } + else if (sfntVersion != 0x00010000 && sfntVersion != 0x4F54544F) // Not TTF or OTF + { + return null; + } + + var numTables = ReadUInt16BigEndian(reader); + reader.ReadUInt16(); // searchRange + reader.ReadUInt16(); // entrySelector + reader.ReadUInt16(); // rangeShift + + // Find the 'name' table + uint nameTableOffset = 0; + uint nameTableLength = 0; + + for (int i = 0; i < numTables; i++) + { + var tag = Encoding.ASCII.GetString(reader.ReadBytes(4)); + reader.ReadUInt32(); // checksum + var offset = ReadUInt32BigEndian(reader); + var length = ReadUInt32BigEndian(reader); + + if (tag == "name") + { + nameTableOffset = offset; + nameTableLength = length; + break; + } + } + + if (nameTableOffset == 0) + return null; + + fileStream.Seek(nameTableOffset, SeekOrigin.Begin); + + var version = ReadUInt16BigEndian(reader); + var count = ReadUInt16BigEndian(reader); + var storageOffset = ReadUInt16BigEndian(reader); + + string? familyName = null; + string? subfamilyName = null; + string? fullName = null; + + for (int i = 0; i < count; i++) + { + var platformID = ReadUInt16BigEndian(reader); + var encodingID = ReadUInt16BigEndian(reader); + var languageID = ReadUInt16BigEndian(reader); + var nameID = ReadUInt16BigEndian(reader); + var length = ReadUInt16BigEndian(reader); + var stringOffset = ReadUInt16BigEndian(reader); + + var isWindows = platformID == 3 && languageID == 0x0409; + var isUnicode = platformID == 0 && (languageID == 0 || languageID == 0x0409); + + if (!isWindows && !isUnicode) + continue; + + var currentPos = fileStream.Position; + + fileStream.Seek(nameTableOffset + storageOffset + stringOffset, SeekOrigin.Begin); + var stringBytes = reader.ReadBytes(length); + + string stringValue; + if (platformID == 3 || platformID == 0) // Windows or Unicode - UTF-16BE + { + stringValue = Encoding.BigEndianUnicode.GetString(stringBytes); + } + else // Macintosh - ASCII/Latin1 + { + stringValue = Encoding.ASCII.GetString(stringBytes); + } + + if (nameID == 4) + fullName = stringValue; + else if (nameID == 1) + familyName = stringValue; + else if (nameID == 2) + subfamilyName = stringValue; + + fileStream.Seek(currentPos, SeekOrigin.Begin); + } + + if (!string.IsNullOrEmpty(fullName)) + return fullName; + + if (!string.IsNullOrEmpty(familyName) && !string.IsNullOrEmpty(subfamilyName)) + { + if (subfamilyName.Equals("Regular", StringComparison.OrdinalIgnoreCase)) + return familyName; + + return $"{familyName} {subfamilyName}"; + } + + return familyName; + } + catch + { + return null; + } + } + + private static uint ReadUInt32BigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return BitConverter.ToUInt32(bytes, 0); + } + + private static ushort ReadUInt16BigEndian(BinaryReader reader) + { + var bytes = reader.ReadBytes(2); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return BitConverter.ToUInt16(bytes, 0); + } + + private static FontStyle GetAvailableFontStyle(FontFamily fontFamily) + { + if (fontFamily.IsStyleAvailable(FontStyle.Regular)) + return FontStyle.Regular; + if (fontFamily.IsStyleAvailable(FontStyle.Bold)) + return FontStyle.Bold; + if (fontFamily.IsStyleAvailable(FontStyle.Italic)) + return FontStyle.Italic; + if (fontFamily.IsStyleAvailable(FontStyle.Bold | FontStyle.Italic)) + return FontStyle.Bold | FontStyle.Italic; + + return FontStyle.Regular; + } + } +} diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index e5bd26e0a617..a14a6c7920d3 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -1222,6 +1222,21 @@ public async Task LoadExtendedItemPropertiesAsync(ListedItem item) { cts.Token.ThrowIfCancellationRequested(); + if (FileExtensionHelpers.IsFontFile(item.FileExtension) && + !item.FileExtension.Equals(".fon", StringComparison.OrdinalIgnoreCase)) + { + var fontDisplayName = FontFileHelper.GetFontName(item.ItemPath); + if (!string.IsNullOrEmpty(fontDisplayName) && fontDisplayName != item.Name) + { + cts.Token.ThrowIfCancellationRequested(); + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + item.ItemNameRaw = fontDisplayName; + }); + await fileListCache.AddDisplayName(item.ItemPath, fontDisplayName); + } + } + var syncStatus = await CheckCloudDriveSyncStatusAsync(matchingStorageFile); var fileFRN = await FileTagsHelper.GetFileFRN(matchingStorageFile); var fileTag = FileTagsHelper.ReadFileTag(item.ItemPath); From 93828f5153b0211465e66be84b1a238a46e06e14 Mon Sep 17 00:00:00 2001 From: Saravanan Ganapathi Date: Thu, 13 Nov 2025 15:11:42 +0530 Subject: [PATCH 2/2] Refactor: Use System.Title property for font names and restrict font thumbnail generation to system fonts folder only --- .../Storage/Helpers/FileThumbnailHelper.cs | 4 ++- src/Files.App/ViewModels/ShellViewModel.cs | 33 +++++++++++-------- src/Files.Shared/Helpers/PathHelpers.cs | 15 +++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs b/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs index 5c132503c0fc..696559a084d6 100644 --- a/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs +++ b/src/Files.App/Utils/Storage/Helpers/FileThumbnailHelper.cs @@ -21,7 +21,9 @@ public static class FileThumbnailHelper if (!isFolder && !iconOptions.HasFlag(IconOptions.ReturnIconOnly)) { var extension = Path.GetExtension(path); - if (FileExtensionHelpers.IsFontFile(extension)) + + //Restrict to only %windir%\fonts + if (FileExtensionHelpers.IsFontFile(extension) && PathHelpers.IsInSystemFontsFolder(path)) { var winrtThumbnail = await FontFileHelper.GetWinRTThumbnailAsync(path, (uint)size); if (winrtThumbnail is not null) diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index a14a6c7920d3..7eec787ab855 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -1222,27 +1222,29 @@ public async Task LoadExtendedItemPropertiesAsync(ListedItem item) { cts.Token.ThrowIfCancellationRequested(); - if (FileExtensionHelpers.IsFontFile(item.FileExtension) && - !item.FileExtension.Equals(".fon", StringComparison.OrdinalIgnoreCase)) + var syncStatus = await CheckCloudDriveSyncStatusAsync(matchingStorageFile); + var fileFRN = await FileTagsHelper.GetFileFRN(matchingStorageFile); + var fileTag = FileTagsHelper.ReadFileTag(item.ItemPath); + var itemType = (item.ItemType == Strings.Folder.GetLocalizedResource()) ? item.ItemType : matchingStorageFile.DisplayType; + + var isSystemFont = FileExtensionHelpers.IsFontFile(item.FileExtension) && + PathHelpers.IsInSystemFontsFolder(item.ItemPath); + var extraProperties = await GetExtraProperties(matchingStorageFile, isSystemFont); + + //Restrict to only %windir%\fonts + if (isSystemFont) { - var fontDisplayName = FontFileHelper.GetFontName(item.ItemPath); - if (!string.IsNullOrEmpty(fontDisplayName) && fontDisplayName != item.Name) + var fontDisplayName = extraProperties?.Result?["System.Title"]?.ToString(); + if (!string.IsNullOrEmpty(fontDisplayName) && fontDisplayName != item.ItemNameRaw) { cts.Token.ThrowIfCancellationRequested(); await dispatcherQueue.EnqueueOrInvokeAsync(() => { item.ItemNameRaw = fontDisplayName; }); - await fileListCache.AddDisplayName(item.ItemPath, fontDisplayName); } } - var syncStatus = await CheckCloudDriveSyncStatusAsync(matchingStorageFile); - var fileFRN = await FileTagsHelper.GetFileFRN(matchingStorageFile); - var fileTag = FileTagsHelper.ReadFileTag(item.ItemPath); - var itemType = (item.ItemType == Strings.Folder.GetLocalizedResource()) ? item.ItemType : matchingStorageFile.DisplayType; - var extraProperties = await GetExtraProperties(matchingStorageFile); - cts.Token.ThrowIfCancellationRequested(); await dispatcherQueue.EnqueueOrInvokeAsync(() => @@ -2098,11 +2100,16 @@ public async Task CheckCloudDriveSyncStatusAsync(IStorageI return (CloudDriveSyncStatus)syncStatus; } - private async Task>?> GetExtraProperties(IStorageItem matchingStorageItem) + private async Task>?> GetExtraProperties(IStorageItem matchingStorageItem, bool includeTitle = false) { if (matchingStorageItem is BaseStorageFile file && file.Properties != null) - return await FilesystemTasks.Wrap(() => file.Properties.RetrievePropertiesAsync(["System.Image.Dimensions", "System.Media.Duration", "System.FileVersion"]).AsTask()); + { + string[] propertiesToRetrieve = includeTitle + ? ["System.Image.Dimensions", "System.Media.Duration", "System.FileVersion", "System.Title"] + : ["System.Image.Dimensions", "System.Media.Duration", "System.FileVersion"]; + return await FilesystemTasks.Wrap(() => file.Properties.RetrievePropertiesAsync(propertiesToRetrieve).AsTask()); + } else if (matchingStorageItem is BaseStorageFolder folder && folder.Properties != null) return await FilesystemTasks.Wrap(() => folder.Properties.RetrievePropertiesAsync(["System.FreeSpace", "System.Capacity", "System.SFGAOFlags"]).AsTask()); diff --git a/src/Files.Shared/Helpers/PathHelpers.cs b/src/Files.Shared/Helpers/PathHelpers.cs index c3127c00587e..435331fcf3ae 100644 --- a/src/Files.Shared/Helpers/PathHelpers.cs +++ b/src/Files.Shared/Helpers/PathHelpers.cs @@ -60,5 +60,20 @@ public static bool TryGetFullPath(string commandName, out string fullPath) return false; } } + + public static bool IsInSystemFontsFolder(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var windowsFontsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts"); + + return fullPath.StartsWith(windowsFontsPath, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } } }