From d831c9b72dc05102a6cdd918dfec56beef108b25 Mon Sep 17 00:00:00 2001 From: "H. Utku Maden" Date: Wed, 1 May 2024 15:22:22 +0300 Subject: [PATCH] Added better font search stuff. --- Quik.Media.Defaults/EnvironmentVariables.cs | 13 + .../Fallback/FallbackFontDatabase.cs | 275 ++++++++++++++++++ Quik.Media.Defaults/FontDatabaseProvider.cs | 93 ++++++ .../Linux/FontConfigFontDatabase.cs | 73 +++-- Quik.Media.Defaults/Linux/LinuxFonts.cs | 11 + .../Quik.Media.Defaults.csproj | 9 +- Quik/Media/Font/FontFace.cs | 2 - Quik/Media/Font/SystemFontFamily.cs | 26 ++ Quik/PAL/IFontDatabase.cs | 13 + Quik/Quik.csproj | 2 +- 10 files changed, 487 insertions(+), 30 deletions(-) create mode 100644 Quik.Media.Defaults/EnvironmentVariables.cs create mode 100644 Quik.Media.Defaults/Fallback/FallbackFontDatabase.cs create mode 100644 Quik.Media.Defaults/FontDatabaseProvider.cs create mode 100644 Quik.Media.Defaults/Linux/LinuxFonts.cs create mode 100644 Quik/Media/Font/SystemFontFamily.cs diff --git a/Quik.Media.Defaults/EnvironmentVariables.cs b/Quik.Media.Defaults/EnvironmentVariables.cs new file mode 100644 index 0000000..aa59895 --- /dev/null +++ b/Quik.Media.Defaults/EnvironmentVariables.cs @@ -0,0 +1,13 @@ +namespace Quik.Media.Defaults +{ + internal static class EnvironmentVariables + { + public const string SerifFont = "QUIK_SERIF_FONT"; + public const string SansFont = "QUIK_SANS_FONT"; + public const string MonospaceFont = "QUIK_MONOSPACE_FONT"; + public const string CursiveFont = "QUIK_CURSIVE_FONT"; + public const string FantasyFont = "QUIK_FANTASY_FONT"; + + public const string FallbackFontDatabase = "QUIK_FALLBACK_FONT_DB"; + } +} \ No newline at end of file diff --git a/Quik.Media.Defaults/Fallback/FallbackFontDatabase.cs b/Quik.Media.Defaults/Fallback/FallbackFontDatabase.cs new file mode 100644 index 0000000..f3dfd19 --- /dev/null +++ b/Quik.Media.Defaults/Fallback/FallbackFontDatabase.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using System.Text.Json; +using Quik.FreeType; +using Quik.Media.Font; +using Quik.PAL; +using Quik.Media.Defaults.Linux; + +namespace Quik.Media.Defaults.Fallback +{ + public class FallbackFontDatabase : IFontDataBase + { + private readonly string DbPath = + Environment.GetEnvironmentVariable(EnvironmentVariables.FallbackFontDatabase) ?? + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "QUIK/fontdb.json"); + + private Dictionary FilesMap { get; } = new Dictionary(); + private Dictionary> ByFamily { get; } = new Dictionary>(); + private Dictionary SystemFonts { get; } = new Dictionary(); + private List All { get; } = new List(); + + IEnumerable IFontDataBase.All => this.All; + + public FallbackFontDatabase(bool rebuild = false) + { + // Load existing database if desired. + List database; + + if(!rebuild) + { + database = LoadDatabase(); + } + else + { + database = new List(); + } + + VerifyDatabase(database); + FlushDatabase(database); + + (FontFace, FileInfo) serif = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.SerifFont, LinuxFonts.DefaultSerifFamilies, this); + SystemFonts[SystemFontFamily.Serif] = serif.Item1; + (FontFace, FileInfo) sans = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.SansFont, LinuxFonts.DefaultSansFamilies, this); + SystemFonts[SystemFontFamily.Sans] = sans.Item1; + (FontFace, FileInfo) mono = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.MonospaceFont, LinuxFonts.DefaultMonospaceFamilies, this); + SystemFonts[SystemFontFamily.Monospace] = mono.Item1; + (FontFace, FileInfo) cursive = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.CursiveFont, LinuxFonts.DefaultCursiveFamilies, this); + SystemFonts[SystemFontFamily.Cursive] = cursive.Item1; + (FontFace, FileInfo) fantasy = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.FantasyFont, LinuxFonts.DefaultFantasyFamilies, this); + SystemFonts[SystemFontFamily.Fantasy] = fantasy.Item1; + + AddFont(serif.Item1, serif.Item2); + AddFont(sans.Item1, sans.Item2); + AddFont(mono.Item1, mono.Item2); + AddFont(cursive.Item1, cursive.Item2); + AddFont(fantasy.Item1, fantasy.Item2); + + database.ForEach(x => AddFont(x.Face, new FileInfo(x.FilePath))); + } + + public FileInfo FontFileInfo(FontFace face) + { + if (FilesMap.TryGetValue(face, out FileInfo info)) + return info; + else + return null; + } + + public Stream Open(FontFace face) + { + return FontFileInfo(face)?.OpenRead() ?? throw new FileNotFoundException(); + } + + public IEnumerable Search(FontFace prototype, FontMatchCriteria criteria = FontMatchCriteria.All) + { + // A bit scuffed and LINQ heavy but it should work. + IEnumerable candidates; + + if (criteria.HasFlag(FontMatchCriteria.Family)) + { + List siblings; + + if (!ByFamily.TryGetValue(prototype.Family, out siblings)) + { + return Enumerable.Empty(); + } + + candidates = siblings; + } + else + { + candidates = All; + } + + return + candidates + .Where(x => + implies(criteria.HasFlag(FontMatchCriteria.Slant), prototype.Slant == x.Slant) || + implies(criteria.HasFlag(FontMatchCriteria.Weight), prototype.Weight == x.Weight) || + implies(criteria.HasFlag(FontMatchCriteria.Stretch), prototype.Stretch == x.Stretch) + ) + .OrderByDescending(x => + + (prototype.Slant == x.Slant ? 1 : 0) + + (prototype.Weight == x.Weight ? 1 : 0) + + (prototype.Stretch == x.Stretch ? 1 : 0) + + confidence(prototype.Family, x.Family) * 3 + ); + + // a => b = a'+b + static bool implies(bool a, bool b) + { + return !a || b; + } + + static int confidence(string target, string testee) + { + int i; + for (i = 0; i < target.Length && i < testee.Length && target[i] == testee[i]; i++); + return i; + } + } + + public FontFace GetSystemFontFace(SystemFontFamily family) + { + return SystemFonts[family]; + } + + private void AddFont(FontFace face, FileInfo file) + { + if (!All.Contains(face)) + All.Add(face); + + FilesMap.TryAdd(face, file); + + if (!ByFamily.TryGetValue(face.Family, out List siblings)) + { + siblings = new List(); + ByFamily.Add(face.Family, siblings); + } + + if (!siblings.Contains(face)) + siblings.Add(face); + } + + private List LoadDatabase() + { + FileInfo info = new FileInfo(DbPath); + + if (!info.Exists) + return new List(); + + using Stream str = info.OpenRead(); + try + { + return JsonSerializer.Deserialize>(str); + } + catch + { + return new List(); + } + } + + private void VerifyDatabase(List db) + { + // Very slow way to do this but how many fonts could a system have on average? + Dictionary entires = new Dictionary(); + + foreach (DbEntry entry in db) + { + FileInfo info = new FileInfo(entry.FilePath); + + // Reprocess fonts that appear like this. + if (!info.Exists) continue; + else if (info.LastWriteTime > entry.AccessTime) continue; + } + + string fontpath = null; + try + { + fontpath = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + if (string.IsNullOrEmpty(fontpath)) + throw new Exception(); + } + catch + { + foreach (string path in FontPaths) + { + if (Directory.Exists(path)) + { + fontpath = path; + break; + } + } + + // rip + if (string.IsNullOrEmpty(fontpath)) + return; + } + + SearchPathForFonts(entires, fontpath); + + db.Clear(); + db.AddRange(entires.Values); + } + + private static void SearchPathForFonts(Dictionary entries, string path) + { + DirectoryInfo dir = new DirectoryInfo(path); + + foreach (FileInfo file in dir.EnumerateFiles()) + { + SearchFileForFonts(entries, file); + } + + foreach (DirectoryInfo directory in dir.EnumerateDirectories()) + { + SearchPathForFonts(entries, directory.FullName); + } + } + + private static void SearchFileForFonts(Dictionary entries, FileInfo file) + { + if (entries.ContainsKey(file.FullName)) + return; + + if (FT.NewFace(FTProvider.Ft, file.FullName, 0, out FTFace face) != FTError.None) + return; + + FontFace facename = FontFace.Parse(face.FamilyName, face.StyleName); + + DbEntry entry = new DbEntry(facename, file.FullName); + entries.Add(file.FullName, entry); + FT.DoneFace(face); + } + + private void FlushDatabase(List db) + { + FileInfo info = new FileInfo(DbPath); + Directory.CreateDirectory(Path.GetDirectoryName(DbPath)); + using Stream str = info.OpenWrite(); + JsonSerializer.Serialize(str, db); + } + + private static readonly string[] FontPaths = new string[] { + "/usr/share/fonts", + }; + + [JsonSerializable(typeof(DbEntry))] + private class DbEntry + { + [JsonIgnore] public FontFace Face => new FontFace(Family, Slant, Weight, Stretch); + public string Family { get; set; } + public FontSlant Slant { get; set; } + public FontWeight Weight { get; set; } + public FontStretch Stretch { get; set; } + public string FilePath { get; set; } + public DateTime AccessTime { get; set; } + + public DbEntry() {} + public DbEntry(FontFace face, string path) + { + Family = face.Family; + Slant = face.Slant; + Weight = face.Weight; + Stretch = face.Stretch; + FilePath = path; + AccessTime = DateTime.Now; + } + } + } +} \ No newline at end of file diff --git a/Quik.Media.Defaults/FontDatabaseProvider.cs b/Quik.Media.Defaults/FontDatabaseProvider.cs new file mode 100644 index 0000000..2a6993a --- /dev/null +++ b/Quik.Media.Defaults/FontDatabaseProvider.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Quik.FreeType; +using Quik.Media.Defaults.Fallback; +using Quik.Media.Defaults.Linux; +using Quik.Media.Font; +using Quik.PAL; + +namespace Quik.Media.Defaults +{ + public static class FontDataBaseProvider + { + public static IFontDataBase Instance { get; } + + static FontDataBaseProvider() + { + try + { + // TODO: add as other operating systems are supported. + if (OperatingSystem.IsLinux()) + { + Instance = new FontConfigFontDatabase(); + } + else + { + Instance = new FallbackFontDatabase(); + } + } + catch (Exception ex) + { + throw new NotSupportedException("Could not load a suitable font database implementation.", ex); + } + } + + public static (FontFace, FileInfo) ResolveSystemFont(string envVar, string defaults, IFontDataBase db) + { + StringBuilder builder = new StringBuilder(); + + string user = Environment.GetEnvironmentVariable(envVar); + if (user != null) + { + builder.Append(user); + builder.Append(':'); + } + + builder.Append(defaults); + + string[] list = builder.ToString().Split(':'); + + foreach (string item in list) + { + if (File.Exists(item)) + { + // Process file. + if (FT.NewFace(FTProvider.Ft, item, 0, out FTFace ftface) != FTError.None) + { + continue; + } + + FontFace face = FontFace.Parse(ftface.FamilyName, ftface.StyleName); + FT.DoneFace(ftface); + + return (face, new FileInfo(item)); + } + else + { + IEnumerable faces = db.Search( + new FontFace(item, FontSlant.Normal, FontWeight.Normal, FontStretch.Normal), + FontMatchCriteria.Family); + + if (faces.Any()) + { + FontFace face = faces.First(); + return (face, db.FontFileInfo(face)); + } + } + } + + try + { + FontFace face = db.GetSystemFontFace(SystemFontFamily.Sans); + return (face, db.FontFileInfo(face)); + } + catch (Exception ex) + { + throw new NotImplementedException("No fallback font yet.", ex); + } + } + } +} \ No newline at end of file diff --git a/Quik.Media.Defaults/Linux/FontConfigFontDatabase.cs b/Quik.Media.Defaults/Linux/FontConfigFontDatabase.cs index aa91d5e..507b1c7 100644 --- a/Quik.Media.Defaults/Linux/FontConfigFontDatabase.cs +++ b/Quik.Media.Defaults/Linux/FontConfigFontDatabase.cs @@ -2,7 +2,10 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Text; +using Quik.FreeType; using Quik.Media.Font; using Quik.PAL; @@ -13,10 +16,12 @@ namespace Quik.Media.Defaults.Linux /// public class FontConfigFontDatabase : IFontDataBase { - private IReadOnlyDictionary FilesMap { get; } - private IReadOnlyDictionary> ByFamily { get; } + private Dictionary FilesMap { get; } = new Dictionary(); + private Dictionary> ByFamily { get; } = new Dictionary>(); + private Dictionary SystemFonts { get; } = new Dictionary(); + private List All { get; } = new List(); - public IEnumerable All { get; } + IEnumerable IFontDataBase.All => this.All; public FontConfigFontDatabase() { @@ -25,10 +30,6 @@ namespace Quik.Media.Defaults.Linux throw new NotSupportedException("This host doesn't have fontconfig installed."); } - List faces = new List(); - Dictionary files = new Dictionary(); - Dictionary> byFamily = new Dictionary>(); - FcConfig config = FontConfig.ConfigGetCurrent(); FcPattern pattern = FontConfig.PatternCreate(); FcObjectSet os = FontConfig.ObjectSetBuild(FontConfig.FAMILY, FontConfig.STYLE, FontConfig.FILE); @@ -49,32 +50,51 @@ namespace Quik.Media.Defaults.Linux string style = Marshal.PtrToStringUTF8(pStyle); FontFace face = FontFace.Parse(family, style); - faces.Add(face); - - if (!byFamily.TryGetValue(face.Family, out List siblings)) - { - siblings = new List(); - byFamily.Add(face.Family, siblings); - } - siblings.Add(face); FontConfig.PatternGetString(current, FontConfig.FILE, 0, out IntPtr pFile); string file = Marshal.PtrToStringAnsi(pFile); - files.TryAdd(face, new FileInfo(file)); + AddFont(face, new FileInfo(file)); } FontConfig.FontSetDestroy(fs); FontConfig.ObjectSetDestroy(os); FontConfig.PatternDestroy(pattern); - All = faces.ToArray(); - FilesMap = files; - ByFamily = new Dictionary>( - byFamily.Select(x => - new KeyValuePair>(x.Key, x.Value) - ) - ); + (FontFace, FileInfo) serif = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.SerifFont, LinuxFonts.DefaultSerifFamilies, this); + SystemFonts[SystemFontFamily.Serif] = serif.Item1; + (FontFace, FileInfo) sans = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.SansFont, LinuxFonts.DefaultSansFamilies, this); + SystemFonts[SystemFontFamily.Sans] = sans.Item1; + (FontFace, FileInfo) mono = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.MonospaceFont, LinuxFonts.DefaultMonospaceFamilies, this); + SystemFonts[SystemFontFamily.Monospace] = mono.Item1; + (FontFace, FileInfo) cursive = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.CursiveFont, LinuxFonts.DefaultCursiveFamilies, this); + SystemFonts[SystemFontFamily.Cursive] = cursive.Item1; + (FontFace, FileInfo) fantasy = FontDataBaseProvider.ResolveSystemFont(EnvironmentVariables.FantasyFont, LinuxFonts.DefaultFantasyFamilies, this); + SystemFonts[SystemFontFamily.Fantasy] = fantasy.Item1; + + AddFont(serif.Item1, serif.Item2); + AddFont(sans.Item1, sans.Item2); + AddFont(mono.Item1, mono.Item2); + AddFont(cursive.Item1, cursive.Item2); + AddFont(fantasy.Item1, fantasy.Item2); + + } + + private void AddFont(FontFace face, FileInfo file) + { + if (!All.Contains(face)) + All.Add(face); + + FilesMap.TryAdd(face, file); + + if (!ByFamily.TryGetValue(face.Family, out List siblings)) + { + siblings = new List(); + ByFamily.Add(face.Family, siblings); + } + + if (!siblings.Contains(face)) + siblings.Add(face); } public IEnumerable Search(FontFace prototype, FontMatchCriteria criteria = FontMatchCriteria.All) @@ -84,7 +104,7 @@ namespace Quik.Media.Defaults.Linux if (criteria.HasFlag(FontMatchCriteria.Family)) { - IReadOnlyList siblings; + List siblings; if (!ByFamily.TryGetValue(prototype.Family, out siblings)) { @@ -139,5 +159,10 @@ namespace Quik.Media.Defaults.Linux { return FontFileInfo(face)?.OpenRead() ?? throw new FileNotFoundException(); } + + public FontFace GetSystemFontFace(SystemFontFamily family) + { + return SystemFonts[family]; + } } } \ No newline at end of file diff --git a/Quik.Media.Defaults/Linux/LinuxFonts.cs b/Quik.Media.Defaults/Linux/LinuxFonts.cs new file mode 100644 index 0000000..11f1168 --- /dev/null +++ b/Quik.Media.Defaults/Linux/LinuxFonts.cs @@ -0,0 +1,11 @@ +namespace Quik.Media.Defaults.Linux +{ + internal static class LinuxFonts + { + public const string DefaultSerifFamilies = "Noto Serif:Nimbus Roman:Liberation Serif:FreeSerif:Times:Times New Roman"; + public const string DefaultSansFamilies = "Noto Sans:Nimbus Sans:Droid Sans:Liberation Sans:FreeSans:Helvetica Neue:Helvetica:Arial"; + public const string DefaultMonospaceFamilies = "Noto Mono:Nimbus Mono PS:Liberation Mono:DejaVu Mono:FreeMono:Lucida Console:Consolas:Courier:Courier New"; + public const string DefaultCursiveFamilies = ""; + public const string DefaultFantasyFamilies = ""; + } +} \ No newline at end of file diff --git a/Quik.Media.Defaults/Quik.Media.Defaults.csproj b/Quik.Media.Defaults/Quik.Media.Defaults.csproj index 0868f36..56849f1 100644 --- a/Quik.Media.Defaults/Quik.Media.Defaults.csproj +++ b/Quik.Media.Defaults/Quik.Media.Defaults.csproj @@ -1,4 +1,4 @@ - + net6.0 @@ -7,10 +7,13 @@ True + + + + + - - diff --git a/Quik/Media/Font/FontFace.cs b/Quik/Media/Font/FontFace.cs index a7d6d61..9fca3e1 100644 --- a/Quik/Media/Font/FontFace.cs +++ b/Quik/Media/Font/FontFace.cs @@ -1,7 +1,5 @@ using System; -using System.Net.WebSockets; using System.Text; -using Quik.Media.Font; namespace Quik.Media.Font { diff --git a/Quik/Media/Font/SystemFontFamily.cs b/Quik/Media/Font/SystemFontFamily.cs new file mode 100644 index 0000000..1395b64 --- /dev/null +++ b/Quik/Media/Font/SystemFontFamily.cs @@ -0,0 +1,26 @@ +namespace Quik.Media.Font +{ + public enum SystemFontFamily + { + /// + /// A font with serifs, like Times New Roman. + /// + Serif, + /// + /// A font without serifs, like Helvetica or Arial. + /// + Sans, + /// + /// A monospace font like Courier New. + /// + Monospace, + /// + /// A cursive font like Lucida Handwriting. + /// + Cursive, + /// + /// An immature font like Comic Sans or Papyrus, nghehehehe. + /// + Fantasy + } +} \ No newline at end of file diff --git a/Quik/PAL/IFontDatabase.cs b/Quik/PAL/IFontDatabase.cs index 43e1b33..ab655f5 100644 --- a/Quik/PAL/IFontDatabase.cs +++ b/Quik/PAL/IFontDatabase.cs @@ -29,6 +29,12 @@ namespace Quik.PAL /// IEnumerable All { get; } + public FontFace Serif => GetSystemFontFace(SystemFontFamily.Serif); + public FontFace Sans => GetSystemFontFace(SystemFontFamily.Sans); + public FontFace Monospace => GetSystemFontFace(SystemFontFamily.Monospace); + public FontFace Cursive => GetSystemFontFace(SystemFontFamily.Cursive); + public FontFace Fantasy => GetSystemFontFace(SystemFontFamily.Fantasy); + /// /// Search for the given font face. /// @@ -50,5 +56,12 @@ namespace Quik.PAL /// The font face to open. /// The stream to the font face. Stream Open(FontFace face); + + /// + /// Get a system font family. + /// + /// The family type to look up. + /// The name of a font in this family. + FontFace GetSystemFontFace(SystemFontFamily family); } } \ No newline at end of file diff --git a/Quik/Quik.csproj b/Quik/Quik.csproj index 28891a9..864574c 100644 --- a/Quik/Quik.csproj +++ b/Quik/Quik.csproj @@ -3,7 +3,7 @@ net6.0 disable - 7.3 + 8 True