From 0f8077905058222c8f0d3d152d52af8f71aad7f7 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Aug 2024 17:24:59 +0200 Subject: [PATCH 1/5] System font API This is a new API that allows operating system fonts to be loaded by the engine and used by content. Fonts are provided in a flat list exposing all the relevant metadata. They are loaded from disk with a Load call. Initial implementation is only for Windows DirectWrite. --- RELEASE-NOTES.md | 4 +- Robust.Client/ClientIoC.cs | 5 + .../GameController/GameController.cs | 2 + Robust.Client/Graphics/Font.cs | 76 +++ .../FontManagement/SystemFontDebug.cs | 15 + .../FontManagement/SystemFontDebugWindow.xaml | 14 + .../SystemFontDebugWindow.xaml.cs | 98 +++ .../SystemFontManagerDirectWrite.cs | 558 ++++++++++++++++++ .../SystemFontManagerFallback.cs | 22 + Robust.Client/Graphics/FontManager.cs | 4 +- Robust.Client/Graphics/IFontManager.cs | 2 +- Robust.Client/Graphics/ISystemFontManager.cs | 127 ++++ Robust.Client/Graphics/SystemFontManager.cs | 82 +++ Robust.Shared/CVars.cs | 10 + 14 files changed, 1015 insertions(+), 4 deletions(-) create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontDebug.cs create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml.cs create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontManagerFallback.cs create mode 100644 Robust.Client/Graphics/ISystemFontManager.cs create mode 100644 Robust.Client/Graphics/SystemFontManager.cs diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 9a8498582a5..3c43c2bdfac 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -39,7 +39,9 @@ END TEMPLATE--> ### New features -*None yet* +* The engine can now load system fonts. + * At the moment only available on Windows. + * See `ISystemFontManager` for API. ### Bugfixes diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index 9ffd524f601..7926bf290c3 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -8,6 +8,7 @@ using Robust.Client.GameStates; using Robust.Client.Graphics; using Robust.Client.Graphics.Clyde; +using Robust.Client.Graphics.FontManagement; using Robust.Client.Input; using Robust.Client.Map; using Robust.Client.Placement; @@ -112,6 +113,8 @@ public static void RegisterIoC(GameController.DisplayMode mode, IDependencyColle deps.Register(); deps.Register(); deps.Register(); + deps.Register(); + deps.Register(); break; case GameController.DisplayMode.Clyde: deps.Register(); @@ -122,6 +125,8 @@ public static void RegisterIoC(GameController.DisplayMode mode, IDependencyColle deps.Register(); deps.Register(); deps.Register(); + deps.Register(); + deps.Register(); break; default: throw new ArgumentOutOfRangeException(); diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs index f855d902ba2..9fb51d6f6cc 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -90,6 +90,7 @@ internal sealed partial class GameController : IGameControllerInternal [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; [Dependency] private readonly IReplayRecordingManagerInternal _replayRecording = default!; [Dependency] private readonly IReflectionManager _reflectionManager = default!; + [Dependency] private readonly ISystemFontManagerInternal _systemFontManager = default!; private IWebViewManagerHook? _webViewHook; @@ -121,6 +122,7 @@ internal bool StartupContinue(DisplayMode displayMode) _taskManager.Initialize(); _parallelMgr.Initialize(); _fontManager.SetFontDpi((uint)_configurationManager.GetCVar(CVars.DisplayFontDpi)); + _systemFontManager.Initialize(); // Load optional Robust modules. LoadOptionalRobustModules(displayMode, _resourceManifest!); diff --git a/Robust.Client/Graphics/Font.cs b/Robust.Client/Graphics/Font.cs index ee7248d6a7b..28760f4a210 100644 --- a/Robust.Client/Graphics/Font.cs +++ b/Robust.Client/Graphics/Font.cs @@ -104,6 +104,12 @@ public VectorFont(FontResource res, int size) Handle = IoCManager.Resolve().MakeInstance(res.FontFaceHandle, size); } + internal VectorFont(IFontInstanceHandle handle, int size) + { + Size = size; + Handle = handle; + } + public override int GetAscent(float scale) => Handle.GetAscent(scale); public override int GetHeight(float scale) => Handle.GetHeight(scale); public override int GetDescent(float scale) => Handle.GetDescent(scale); @@ -219,4 +225,74 @@ public override float DrawChar(DrawingHandleScreen handle, Rune rune, Vector2 ba return null; } } + + /// + /// Possible values for font weights. Larger values have thicker font strokes. + /// + /// + /// + /// These values are based on the usWeightClass property of the OpenType specification: + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass + /// + /// + /// + public enum FontWeight : ushort + { + Thin = 100, + ExtraLight = 200, + UltraLight = ExtraLight, + Light = 300, + SemiLight = 350, + Normal = 400, + Regular = Normal, + Medium = 500, + SemiBold = 600, + DemiBold = SemiBold, + Bold = 700, + ExtraBold = 800, + UltraBold = ExtraBold, + Black = 900, + Heavy = Black, + ExtraBlack = 950, + UltraBlack = ExtraBlack, + } + + /// + /// Possible slant values for fonts. + /// + /// + public enum FontSlant : byte + { + // NOTE: Enum values correspond to DWRITE_FONT_STYLE. + Normal = 0, + Oblique = 1, + + // FUN FACT: they're called "italics" because they look like the Leaning Tower of Pisa. + // Don't fact-check that. + Italic = 2 + } + + /// + /// Possible values for font widths. Larger values are proportionally wider. + /// + /// + /// + /// These values are based on the usWidthClass property of the OpenType specification: + /// https://learn.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass + /// + /// + /// + public enum FontWidth : ushort + { + UltraCondensed = 1, + ExtraCondensed = 2, + Condensed = 3, + SemiCondensed = 4, + Normal = 5, + Medium = Normal, + SemiExpanded = 6, + Expanded = 7, + ExtraExpanded = 8, + UltraExpanded = 9, + } } diff --git a/Robust.Client/Graphics/FontManagement/SystemFontDebug.cs b/Robust.Client/Graphics/FontManagement/SystemFontDebug.cs new file mode 100644 index 00000000000..8bead2c1344 --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontDebug.cs @@ -0,0 +1,15 @@ +using Robust.Shared.Console; + +namespace Robust.Client.Graphics.FontManagement; + +internal sealed class SystemFontDebugCommand : IConsoleCommand +{ + public string Command => "system_font_debug"; + public string Description => ""; + public string Help => ""; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + new SystemFontDebugWindow().OpenCentered(); + } +} diff --git a/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml b/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml new file mode 100644 index 00000000000..bee4a157e3d --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml.cs b/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml.cs new file mode 100644 index 00000000000..8061d766fcc --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontDebugWindow.xaml.cs @@ -0,0 +1,98 @@ +using System.Linq; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.Shared.Utility; + +namespace Robust.Client.Graphics.FontManagement; + +[GenerateTypedNameReferences] +internal sealed partial class SystemFontDebugWindow : DefaultWindow +{ + private static readonly int[] ExampleFontSizes = [8, 12, 16, 24, 36]; + private const string ExampleString = "The quick brown fox jumps over the lazy dog"; + + [Dependency] private readonly ISystemFontManager _systemFontManager = default!; + + public SystemFontDebugWindow() + { + IoCManager.InjectDependencies(this); + RobustXamlLoader.Load(this); + + var buttonGroup = new ButtonGroup(); + + foreach (var group in _systemFontManager.SystemFontFaces.GroupBy(k => k.FamilyName).OrderBy(k => k.Key)) + { + var fonts = group.ToArray(); + SelectorContainer.AddChild(new Selector(this, buttonGroup, group.Key, fonts)); + } + } + + private void SelectFontFamily(ISystemFontFace[] fonts) + { + FamilyLabel.Text = fonts[0].FamilyName; + + FaceContainer.RemoveAllChildren(); + + foreach (var font in fonts) + { + var exampleContainer = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + Margin = new Thickness(8) + }; + + foreach (var size in ExampleFontSizes) + { + var fontInstance = font.Load(size); + + var richTextLabel = new RichTextLabel + { + Stylesheet = new Stylesheet([ + StylesheetHelpers.Element().Prop("font", fontInstance) + ]), + }; + richTextLabel.SetMessage(FormattedMessage.FromUnformatted(ExampleString)); + exampleContainer.AddChild(richTextLabel); + } + + FaceContainer.AddChild(new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + Children = + { + new RichTextLabel + { + Text = $""" + {font.FullName} + Family: "{font.FamilyName}", face: "{font.FaceName}", PostScript = "{font.PostscriptName}" + Weight: {font.Weight} ({(int) font.Weight}), slant: {font.Slant} ({(int) font.Slant}), width: {font.Width} ({(int) font.Width}) + """, + }, + exampleContainer + }, + Margin = new Thickness(0, 0, 0, 8) + }); + } + } + + private sealed class Selector : Control + { + public Selector(SystemFontDebugWindow window, ButtonGroup group, string family, ISystemFontFace[] fonts) + { + var button = new Button + { + Text = family, + Group = group, + ToggleMode = true + }; + AddChild(button); + + button.OnPressed += _ => window.SelectFontFamily(fonts); + } + } +} diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs new file mode 100644 index 00000000000..860a061b128 --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs @@ -0,0 +1,558 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Log; +using Robust.Shared.Utility; +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; +using static TerraFX.Interop.DirectX.DWRITE_FACTORY_TYPE; +using static TerraFX.Interop.DirectX.DWRITE_FONT_PROPERTY_ID; +using static TerraFX.Interop.Windows.Windows; + +namespace Robust.Client.Graphics.FontManagement; + +/// +/// Implementation of that uses DirectWrite on Windows. +/// +internal sealed unsafe class SystemFontManagerDirectWrite : ISystemFontManagerInternal +{ + // For future implementors of other platforms: + // a significant amount of code in this file will be shareable with that of other platforms, + // so some refactoring is warranted. + + /// + /// The "standard" locale used when looking up the PostScript name of a font face. + /// + /// + /// + /// Font files allow the PostScript name to be localized, however in practice + /// we would really like to have a language-unambiguous identifier to refer to a font file. + /// We use this locale (en-US) to look up teh PostScript font name, if there are multiple provided. + /// This matches the behavior of the Local Font Access web API: + /// https://wicg.github.io/local-font-access/#concept-font-representation + /// + /// + private static readonly CultureInfo StandardLocale = new("en-US", false); + + private readonly IConfigurationManager _cfg; + private readonly IFontManagerInternal _fontManager; + private readonly ISawmill _sawmill; + + private readonly object _lock = new(); + private readonly List _fonts = []; + + private IDWriteFactory3* _dWriteFactory; + private IDWriteFontSet* _systemFontSet; + + public bool IsSupported => true; + public IEnumerable SystemFontFaces { get; } + + /// + /// Implementation of that uses DirectWrite on Windows. + /// + public SystemFontManagerDirectWrite( + ILogManager logManager, + IConfigurationManager cfg, + IFontManagerInternal fontManager) + { + _cfg = cfg; + _fontManager = fontManager; + _sawmill = logManager.GetSawmill("font.system"); + + SystemFontFaces = _fonts.AsReadOnly(); + } + + public void Initialize() + { + CreateDWriteFactory(); + + _systemFontSet = GetSystemFontSet(_dWriteFactory); + + lock (_lock) + { + var fontCount = _systemFontSet->GetFontCount(); + for (var i = 0u; i < fontCount; i++) + { + LoadSingleFontFromSet(_systemFontSet, i); + } + } + + _sawmill.Verbose($"Loaded {_fonts.Count} fonts"); + } + + public void Shutdown() + { + _systemFontSet->Release(); + _systemFontSet = null; + + _dWriteFactory->Release(); + _dWriteFactory = null; + + lock (_lock) + { + foreach (var systemFont in _fonts) + { + systemFont.FontFace->Release(); + } + + _fonts.Clear(); + } + } + + private void LoadSingleFontFromSet(IDWriteFontSet* set, uint fontIndex) + { + // Get basic parameters that every font should probably have? + if (!TryGetStringsSet(set, fontIndex, DWRITE_FONT_PROPERTY_ID_POSTSCRIPT_NAME, out var postscriptNames)) + return; + + if (!TryGetStringsSet(set, fontIndex, DWRITE_FONT_PROPERTY_ID_FULL_NAME, out var fullNames)) + return; + + if (!TryGetStringsSet(set, fontIndex, DWRITE_FONT_PROPERTY_ID_FAMILY_NAME, out var familyNames)) + return; + + if (!TryGetStringsSet(set, fontIndex, DWRITE_FONT_PROPERTY_ID_FACE_NAME, out var faceNames)) + return; + + // I assume these parameters can't be missing in practice, but better safe than sorry. + TryGetStrings(set, fontIndex, DWRITE_FONT_PROPERTY_ID_WEIGHT, out var weight); + TryGetStrings(set, fontIndex, DWRITE_FONT_PROPERTY_ID_STYLE, out var style); + TryGetStrings(set, fontIndex, DWRITE_FONT_PROPERTY_ID_STRETCH, out var stretch); + + var parsedWeight = ParseFontWeight(weight); + var parsedSlant = ParseFontSlant(style); + var parsedWidth = ParseFontWidth(stretch); + + IDWriteFontFaceReference* reference = null; + var result = set->GetFontFaceReference(fontIndex, &reference); + ThrowIfFailed(result); + + var handle = new Handle(this, reference) + { + PostscriptName = GetLocalizedForLocaleOrFirst(postscriptNames, StandardLocale), + FullNames = fullNames, + FamilyNames = familyNames, + FaceNames = faceNames, + Weight = parsedWeight, + Slant = parsedSlant, + Width = parsedWidth + }; + + _fonts.Add(handle); + } + + private static FontWeight ParseFontWeight(DWriteLocalizedString[]? strings) + { + if (strings == null) + return FontWeight.Regular; + + return (FontWeight)Parse.Int32(strings[0].Value); + } + + private static FontSlant ParseFontSlant(DWriteLocalizedString[]? strings) + { + if (strings == null) + return FontSlant.Normal; + + return (FontSlant)Parse.Int32(strings[0].Value); + } + + private static FontWidth ParseFontWidth(DWriteLocalizedString[]? strings) + { + if (strings == null) + return FontWidth.Normal; + + return (FontWidth)Parse.Int32(strings[0].Value); + } + + private void CreateDWriteFactory() + { + fixed (IDWriteFactory3** pFactory = &_dWriteFactory) + { + var result = DirectX.DWriteCreateFactory( + DWRITE_FACTORY_TYPE_SHARED, + __uuidof(), + (IUnknown**)pFactory); + + ThrowIfFailed(result); + } + } + + private IDWriteFontSet* GetSystemFontSet(IDWriteFactory3* factory) + { + IDWriteFactory6* factory6; + IDWriteFontSet* fontSet; + var result = factory->QueryInterface(__uuidof(), (void**)&factory6); + if (result.SUCCEEDED) + { + _sawmill.Verbose("IDWriteFactory6 available, using newer GetSystemFontSet"); + + result = factory6->GetSystemFontSet( + _cfg.GetCVar(CVars.FontWindowsDownloadable), + (IDWriteFontSet1**)(&fontSet)); + + factory6->Release(); + } + else + { + _sawmill.Verbose("IDWriteFactory6 not available"); + + result = factory->GetSystemFontSet(&fontSet); + } + + ThrowIfFailed(result, "GetSystemFontSet"); + return fontSet; + } + + private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) + { + IDWriteFontFile* file = null; + IDWriteFontFileLoader* loader = null; + + try + { + var result = fontFace->GetFontFile(&file); + ThrowIfFailed(result, "IDWriteFontFaceReference::GetFontFile"); + result = file->GetLoader(&loader); + ThrowIfFailed(result, "IDWriteFontFile::GetLoader"); + + void* referenceKey; + uint referenceKeyLength; + result = file->GetReferenceKey(&referenceKey, &referenceKeyLength); + ThrowIfFailed(result, "IDWriteFontFile::GetReferenceKey"); + + IDWriteFontFileStream* stream; + result = loader->CreateStreamFromKey(referenceKey, referenceKeyLength, &stream); + ThrowIfFailed(result, "IDWriteFontFileLoader::CreateStreamFromKey"); + + using (var streamObject = new DirectWriteStream(stream)) + { + // TODO: This sucks cuz if you have multiple fonts in one file, + // we'll be duplicating the data in memory every time. + // Not sure how to avoid this, as it needs a way to see if two IDWriteFontFile objects are identical. + // + // I can think of a few solutions, given DirectWrite's API: + // * Try to get the file path directly, by casting to an IDWriteLocalFontFileLoader. + // This seems to work in practice for most fonts but it's probably an implementation detail. + // * Hash the file contents. + // * See if we can reference compare the pointers somehow? Probably not. + // + // I'm not sure how important this is for memory savings. + // You know come to think of it wouldn't it make sense if we mmapped the font files... + return _fontManager.Load(streamObject, (int)fontFace->GetFontFaceIndex()); + } + } + finally + { + if (file != null) + file->Release(); + if (loader != null) + loader->Release(); + } + } + + private static bool TryGetStrings( + IDWriteFontSet* set, + uint listIndex, + DWRITE_FONT_PROPERTY_ID property, + [NotNullWhen(true)] out DWriteLocalizedString[]? strings) + { + BOOL exists; + IDWriteLocalizedStrings* dWriteStrings = null; + var result = set->GetPropertyValues( + listIndex, + property, + &exists, + &dWriteStrings); + ThrowIfFailed(result, "IDWriteFontSet::GetPropertyValues"); + + if (!exists) + { + strings = null; + return false; + } + + try + { + strings = GetStrings(dWriteStrings); + return true; + } + finally + { + dWriteStrings->Release(); + } + } + + private static bool TryGetStringsSet( + IDWriteFontSet* set, + uint listIndex, + DWRITE_FONT_PROPERTY_ID property, + out LocalizedStringSet strings) + { + if (!TryGetStrings(set, listIndex, property, out var stringsArray)) + { + strings = default; + return false; + } + + strings = StringsToSet(stringsArray); + return true; + } + + private static DWriteLocalizedString[] GetStrings(IDWriteLocalizedStrings* localizedStrings) + { + IDWriteStringList* list; + ThrowIfFailed(localizedStrings->QueryInterface(__uuidof(), (void**)&list)); + + try + { + return GetStrings(list); + } + finally + { + list->Release(); + } + } + + private static DWriteLocalizedString[] GetStrings(IDWriteStringList* stringList) + { + var array = new DWriteLocalizedString[stringList->GetCount()]; + + for (var i = 0; i < array.Length; i++) + { + uint length; + + ThrowIfFailed(stringList->GetStringLength((uint)i, &length), "IDWriteStringList::GetStringLength"); + var arr = new char[length + 1]; + fixed (char* pArr = arr) + { + ThrowIfFailed(stringList->GetString((uint)i, pArr, (uint)arr.Length), "IDWriteStringList::GetString"); + } + + var value = new string(arr, 0, (int)length); + + ThrowIfFailed(stringList->GetLocaleNameLength((uint)i, &length), "IDWriteStringList::GetLocaleNameLength"); + arr = new char[length + 1]; + fixed (char* pArr = arr) + { + ThrowIfFailed( + stringList->GetLocaleName((uint)i, pArr, (uint)arr.Length), + "IDWriteStringList::GetLocaleName"); + } + + var localeName = new string(arr, 0, (int)length); + + array[i] = new DWriteLocalizedString(value, localeName); + } + + return array; + } + + private static LocalizedStringSet StringsToSet(DWriteLocalizedString[] strings) + { + var dict = new Dictionary(); + + foreach (var (value, localeName) in strings) + { + dict[localeName] = value; + } + + return new LocalizedStringSet { Primary = strings[0].LocaleName, Values = dict }; + } + + private static string GetLocalizedForLocaleOrFirst(LocalizedStringSet set, CultureInfo culture) + { + var matchCulture = culture; + while (!Equals(matchCulture, CultureInfo.InvariantCulture)) + { + if (set.Values.TryGetValue(culture.Name, out var value)) + return value; + + matchCulture = matchCulture.Parent; + } + + return set.Values[set.Primary]; + } + + private sealed class Handle(SystemFontManagerDirectWrite parent, IDWriteFontFaceReference* fontFace) : ISystemFontFace + { + private IFontFaceHandle? _cachedFont; + + public required LocalizedStringSet FullNames; + public required LocalizedStringSet FamilyNames; + public required LocalizedStringSet FaceNames; + public readonly IDWriteFontFaceReference* FontFace = fontFace; + + public required string PostscriptName { get; init; } + public string FullName => GetLocalizedFullName(CultureInfo.CurrentCulture); + public string FamilyName => GetLocalizedFamilyName(CultureInfo.CurrentCulture); + public string FaceName => GetLocalizedFaceName(CultureInfo.CurrentCulture); + + public string GetLocalizedFullName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FullNames, culture); + } + + public string GetLocalizedFamilyName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FamilyNames, culture); + } + + public string GetLocalizedFaceName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FaceNames, culture); + } + + public required FontWeight Weight { get; init; } + public required FontSlant Slant { get; init; } + public required FontWidth Width { get; init; } + + public Font Load(int size) + { + var handle = GetFaceHandle(); + + var instance = parent._fontManager.MakeInstance(handle, size); + + return new VectorFont(instance, size); + } + + private IFontFaceHandle GetFaceHandle() + { + lock (parent._lock) + { + if (_cachedFont != null) + return _cachedFont; + + parent._sawmill.Verbose($"Loading system font face: {PostscriptName}"); + + return _cachedFont = parent.LoadFontFace(FontFace); + } + } + } + + /// + /// A simple implementation of a .NET Stream over a IDWriteFontFileStream. + /// + private sealed class DirectWriteStream : Stream + { + private readonly IDWriteFontFileStream* _stream; + private readonly ulong _size; + + private ulong _position; + private bool _disposed; + + public DirectWriteStream(IDWriteFontFileStream* stream) + { + _stream = stream; + + fixed (ulong* pSize = &_size) + { + var result = _stream->GetFileSize(pSize); + ThrowIfFailed(result, "IDWriteFontFileStream::GetFileSize"); + } + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + if (_disposed) + throw new ObjectDisposedException(nameof(DirectWriteStream)); + + var readLength = (uint)buffer.Length; + if (readLength + _position > _size) + readLength = (uint)(_size - _position); + + void* fragmentStart; + void* fragmentContext; + + var result = _stream->ReadFileFragment(&fragmentStart, _position, readLength, &fragmentContext); + ThrowIfFailed(result); + + var data = new ReadOnlySpan(fragmentStart, (int)readLength); + data.CopyTo(buffer); + + _stream->ReleaseFileFragment(fragmentContext); + + _position += readLength; + return (int)readLength; + } + + public override long Seek(long offset, SeekOrigin origin) + { + switch (origin) + { + case SeekOrigin.Begin: + Position = offset; + break; + case SeekOrigin.Current: + Position += offset; + break; + case SeekOrigin.End: + Position = Length + offset; + break; + default: + throw new ArgumentOutOfRangeException(nameof(origin), origin, null); + } + + return Position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => (long)_size; + + public override long Position + { + get => (long)_position; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan((ulong)value, _size); + + _position = (ulong)value; + } + } + + protected override void Dispose(bool disposing) + { + _stream->Release(); + _disposed = true; + } + } + + private record struct DWriteLocalizedString(string Value, string LocaleName); + + private struct LocalizedStringSet + { + /// + /// The first locale to appear in the list of localized strings. + /// Used as fallback if the desired locale is not provided. + /// + public required string Primary; + public required Dictionary Values; + } +} diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerFallback.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerFallback.cs new file mode 100644 index 00000000000..31638699eca --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerFallback.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Robust.Client.Graphics.FontManagement; + +/// +/// A fallback implementation of that just loads no fonts. +/// +internal sealed class SystemFontManagerFallback : ISystemFontManagerInternal +{ + public void Initialize() + { + + } + + public void Shutdown() + { + + } + + public bool IsSupported => false; + public IEnumerable SystemFontFaces => []; +} diff --git a/Robust.Client/Graphics/FontManager.cs b/Robust.Client/Graphics/FontManager.cs index 19c0d410a9f..d76df21863d 100644 --- a/Robust.Client/Graphics/FontManager.cs +++ b/Robust.Client/Graphics/FontManager.cs @@ -34,12 +34,12 @@ public FontManager(IClyde clyde) _library = new Library(); } - public IFontFaceHandle Load(Stream stream) + public IFontFaceHandle Load(Stream stream, int index = 0) { // Freetype directly operates on the font memory managed by us. // As such, the font data should be pinned in POH. var fontData = stream.CopyToPinnedArray(); - var face = new Face(_library, fontData, 0); + var face = new Face(_library, fontData, index); var handle = new FontFaceHandle(face); return handle; } diff --git a/Robust.Client/Graphics/IFontManager.cs b/Robust.Client/Graphics/IFontManager.cs index 34cacb93dc0..6451733eaca 100644 --- a/Robust.Client/Graphics/IFontManager.cs +++ b/Robust.Client/Graphics/IFontManager.cs @@ -10,7 +10,7 @@ public interface IFontManager } internal interface IFontManagerInternal : IFontManager { - IFontFaceHandle Load(Stream stream); + IFontFaceHandle Load(Stream stream, int index = 0); IFontInstanceHandle MakeInstance(IFontFaceHandle handle, int size); void SetFontDpi(uint fontDpi); } diff --git a/Robust.Client/Graphics/ISystemFontManager.cs b/Robust.Client/Graphics/ISystemFontManager.cs new file mode 100644 index 00000000000..b96451bf924 --- /dev/null +++ b/Robust.Client/Graphics/ISystemFontManager.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Robust.Client.Graphics; + +/// +/// Provides access to fonts installed on the user's operating system. +/// +/// +/// +/// Different operating systems ship different fonts, so you should generally not rely on any one +/// specific font being available. This system is primarily provided for allowing user preference. +/// +/// +/// +public interface ISystemFontManager +{ + /// + /// Whether access to system fonts is currently supported on this platform. + /// + bool IsSupported { get; } + + /// + /// The list of font face available from the operating system. + /// + IEnumerable SystemFontFaces { get; } +} + +/// +/// A single font face, provided by the user's operating system. +/// +/// +public interface ISystemFontFace +{ + /// + /// The PostScript name of the font face. + /// This is generally the closest to an unambiguous unique identifier as you're going to get. + /// + /// + /// + /// For example, "Arial-ItalicMT" + /// + /// + string PostscriptName { get; } + + /// + /// The full name of the font face, localized to the current locale. + /// + /// + /// + /// For example, "Arial Cursiva" + /// + /// + /// + string FullName { get; } + + /// + /// The family name of the font face, localized to the current locale. + /// + /// + /// + /// For example, "Arial" + /// + /// + /// + string FamilyName { get; } + + /// + /// The face name (or "style name") of the font face, localized to the current locale. + /// + /// + /// + /// For example, "Cursiva" + /// + /// + /// + string FaceName { get; } + + /// + /// Get the , localized to a specific locale. + /// + /// The locale to fetch the localized string for. + string GetLocalizedFullName(CultureInfo culture); + + /// + /// Get the , localized to a specific locale. + /// + /// The locale to fetch the localized string for. + string GetLocalizedFamilyName(CultureInfo culture); + + /// + /// Get the , localized to a specific locale. + /// + /// The locale to fetch the localized string for. + string GetLocalizedFaceName(CultureInfo culture); + + /// + /// The weight of the font face. + /// + FontWeight Weight { get; } + + /// + /// The slant of the font face. + /// + FontSlant Slant { get; } + + /// + /// The width of the font face. + /// + FontWidth Width { get; } + + /// + /// Load the font face so that it can be used in-engine. + /// + /// The size to load the font at. + /// A font object that can be used to render text. + Font Load(int size); +} + +/// +/// Engine-internal API for . +/// +internal interface ISystemFontManagerInternal : ISystemFontManager +{ + void Initialize(); + void Shutdown(); +} diff --git a/Robust.Client/Graphics/SystemFontManager.cs b/Robust.Client/Graphics/SystemFontManager.cs new file mode 100644 index 00000000000..c12f0aeb2e3 --- /dev/null +++ b/Robust.Client/Graphics/SystemFontManager.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Robust.Client.Graphics.FontManagement; +using Robust.Shared.Configuration; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Timing; + +namespace Robust.Client.Graphics; + +/// +/// Implementation of that proxies to platform-specific implementations, +/// and adds additional logging. +/// +internal sealed class SystemFontManager : ISystemFontManagerInternal, IPostInjectInit +{ + [Dependency] private readonly IFontManagerInternal _fontManager = default!; + [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + + private ISawmill _sawmill = default!; + + private ISystemFontManagerInternal _implementation = default!; + + public bool IsSupported => _implementation.IsSupported; + public IEnumerable SystemFontFaces => _implementation.SystemFontFaces; + + public void Initialize() + { + _implementation = GetImplementation(); + _sawmill.Verbose($"Using {_implementation.GetType()}"); + + _sawmill.Debug("Initializing system font manager implementation"); + try + { + var sw = RStopwatch.StartNew(); + _implementation.Initialize(); + _sawmill.Debug($"Done initializing system font manager in {sw.Elapsed}"); + } + catch (Exception e) + { + // This is a non-critical engine system that has to parse significant amounts of external data. + // Best to fail gracefully to avoid full startup failures. + + _sawmill.Error($"Error while initializing system font manager, resorting to fallback: {e}"); + _implementation = new SystemFontManagerFallback(); + } + } + + public void Shutdown() + { + _sawmill.Verbose("Shutting down system font manager"); + + try + { + _implementation.Shutdown(); + } + catch (Exception e) + { + _sawmill.Error($"Exception shutting down system font manager: {e}"); + return; + } + + _sawmill.Verbose("Successfully shut down system font manager"); + } + + private ISystemFontManagerInternal GetImplementation() + { + if (OperatingSystem.IsWindows()) + return new SystemFontManagerDirectWrite(_logManager, _cfg, _fontManager); + + // TODO: Linux and macOS implementations. + + return new SystemFontManagerFallback(); + } + + void IPostInjectInit.PostInject() + { + _sawmill = _logManager.GetSawmill("font.system"); + _sawmill.Level = LogLevel.Verbose; + } +} diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index c781d1eabee..b2b5244ce97 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1795,5 +1795,15 @@ protected CVars() /// public static readonly CVarDef ToolshedNearbyEntitiesLimit = CVarDef.Create("toolshed.nearby_entities_limit", 5, CVar.SERVER | CVar.REPLICATED); + + /* + * FONT + */ + + /// + /// If true, allow Windows "downloadable" fonts to be exposed to the system fonts API. + /// + public static readonly CVarDef FontWindowsDownloadable = + CVarDef.Create("font.windows_downloadable", false, CVar.CLIENTONLY | CVar.ARCHIVE); } } From e7b5665c1ae7b566caf096eb55484c005a5c20f8 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Aug 2024 23:35:37 +0200 Subject: [PATCH 2/5] Load system fonts as memory mapped files if possible. This allows sharing the font file memory with other processes which is always good. --- .../SystemFontManagerDirectWrite.cs | 94 +++++++++++++++---- Robust.Client/Graphics/FontManager.cs | 44 ++++++++- Robust.Client/Graphics/IFontManager.cs | 10 +- 3 files changed, 125 insertions(+), 23 deletions(-) diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs index 860a061b128..a48468586d2 100644 --- a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.IO.MemoryMappedFiles; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.Log; @@ -225,24 +226,49 @@ private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) result = file->GetReferenceKey(&referenceKey, &referenceKeyLength); ThrowIfFailed(result, "IDWriteFontFile::GetReferenceKey"); - IDWriteFontFileStream* stream; - result = loader->CreateStreamFromKey(referenceKey, referenceKeyLength, &stream); - ThrowIfFailed(result, "IDWriteFontFileLoader::CreateStreamFromKey"); + IDWriteLocalFontFileLoader* localLoader; + result = loader->QueryInterface(__uuidof(), (void**)&localLoader); + if (result.SUCCEEDED) + { + _sawmill.Verbose("Loading font face via memory mapped file..."); - using (var streamObject = new DirectWriteStream(stream)) + // We can get the local file path on disk. This means we can directly load it via mmap. + uint filePathLength; + ThrowIfFailed( + localLoader->GetFilePathLengthFromKey(referenceKey, referenceKeyLength, &filePathLength), + "IDWriteLocalFontFileLoader::GetFilePathLengthFromKey"); + var filePath = new char[filePathLength + 1]; + fixed (char* pFilePath = filePath) + { + ThrowIfFailed( + localLoader->GetFilePathFromKey( + referenceKey, + referenceKeyLength, + pFilePath, + (uint)filePath.Length), + "IDWriteLocalFontFileLoader::GetFilePathFromKey"); + } + + var path = new string(filePath, 0, (int)filePathLength); + + localLoader->Release(); + + return _fontManager.Load(new MemoryMappedFontMemoryHandle(path)); + } + else { - // TODO: This sucks cuz if you have multiple fonts in one file, - // we'll be duplicating the data in memory every time. - // Not sure how to avoid this, as it needs a way to see if two IDWriteFontFile objects are identical. - // - // I can think of a few solutions, given DirectWrite's API: - // * Try to get the file path directly, by casting to an IDWriteLocalFontFileLoader. - // This seems to work in practice for most fonts but it's probably an implementation detail. - // * Hash the file contents. - // * See if we can reference compare the pointers somehow? Probably not. - // - // I'm not sure how important this is for memory savings. - // You know come to think of it wouldn't it make sense if we mmapped the font files... + _sawmill.Verbose("Loading font face via stream..."); + + // DirectWrite doesn't give us anything to go with for this file, read it into regular memory. + // If the font file has multiple faces, which is possible, then this approach will duplicate memory. + // That sucks, but I'm really not sure whether there's any way around this short of + // comparing the memory contents by hashing to check equality. + // As I'm pretty sure we can't like reference equality check the font objects somehow. + IDWriteFontFileStream* stream; + result = loader->CreateStreamFromKey(referenceKey, referenceKeyLength, &stream); + ThrowIfFailed(result, "IDWriteFontFileLoader::CreateStreamFromKey"); + + using var streamObject = new DirectWriteStream(stream); return _fontManager.Load(streamObject, (int)fontFace->GetFontFaceIndex()); } } @@ -555,4 +581,40 @@ private struct LocalizedStringSet public required string Primary; public required Dictionary Values; } + + private sealed class MemoryMappedFontMemoryHandle : IFontMemoryHandle + { + private readonly MemoryMappedFile _mappedFile; + private readonly MemoryMappedViewAccessor _accessor; + + public MemoryMappedFontMemoryHandle(string filePath) + { + _mappedFile = MemoryMappedFile.CreateFromFile( + filePath, + FileMode.Open, + null, + 0, + MemoryMappedFileAccess.Read); + + _accessor = _mappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + } + + public byte* GetData() + { + byte* pointer = null; + _accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref pointer); + return pointer; + } + + public nint GetDataSize() + { + return (nint)_accessor.Capacity; + } + + public void Dispose() + { + _accessor.Dispose(); + _mappedFile.Dispose(); + } + } } diff --git a/Robust.Client/Graphics/FontManager.cs b/Robust.Client/Graphics/FontManager.cs index d76df21863d..5964753bb28 100644 --- a/Robust.Client/Graphics/FontManager.cs +++ b/Robust.Client/Graphics/FontManager.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Text; using JetBrains.Annotations; using Robust.Client.Utility; -using Robust.Shared.Graphics; using Robust.Shared.Maths; using Robust.Shared.Utility; using SharpFont; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using TerraFX.Interop.Windows; namespace Robust.Client.Graphics { @@ -39,8 +38,13 @@ public IFontFaceHandle Load(Stream stream, int index = 0) // Freetype directly operates on the font memory managed by us. // As such, the font data should be pinned in POH. var fontData = stream.CopyToPinnedArray(); - var face = new Face(_library, fontData, index); - var handle = new FontFaceHandle(face); + return Load(new ArrayMemoryHandle(fontData), index); + } + + public unsafe IFontFaceHandle Load(IFontMemoryHandle memory, int index = 0) + { + var face = new Face(_library, (nint) memory.GetData(), checked((int)memory.GetDataSize()), index); + var handle = new FontFaceHandle(face, memory); return handle; } @@ -235,10 +239,13 @@ private static Image MonoBitMapToImage(FTBitmap bitmap) private sealed class FontFaceHandle : IFontFaceHandle { + // Keep this alive to avoid it being GC'd. + private readonly IFontMemoryHandle _memoryHandle; public Face Face { get; } - public FontFaceHandle(Face face) + public FontFaceHandle(Face face, IFontMemoryHandle memoryHandle) { + _memoryHandle = memoryHandle; Face = face; } } @@ -377,5 +384,32 @@ public sealed class GlyphInfo public CharMetrics Metrics; public AtlasTexture? Texture; } + + private sealed class ArrayMemoryHandle(byte[] array) : IFontMemoryHandle + { + private GCHandle _gcHandle = GCHandle.Alloc(array, GCHandleType.Pinned); + + public unsafe byte* GetData() + { + return (byte*) _gcHandle.AddrOfPinnedObject(); + } + + public IntPtr GetDataSize() + { + return array.Length; + } + + public void Dispose() + { + _gcHandle.Free(); + _gcHandle = default; + GC.SuppressFinalize(this); + } + + ~ArrayMemoryHandle() + { + Dispose(); + } + } } } diff --git a/Robust.Client/Graphics/IFontManager.cs b/Robust.Client/Graphics/IFontManager.cs index 6451733eaca..c8b994c4e58 100644 --- a/Robust.Client/Graphics/IFontManager.cs +++ b/Robust.Client/Graphics/IFontManager.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Text; using Robust.Shared.Graphics; @@ -11,6 +12,7 @@ public interface IFontManager internal interface IFontManagerInternal : IFontManager { IFontFaceHandle Load(Stream stream, int index = 0); + IFontFaceHandle Load(IFontMemoryHandle memory, int index = 0); IFontInstanceHandle MakeInstance(IFontFaceHandle handle, int size); void SetFontDpi(uint fontDpi); } @@ -22,8 +24,6 @@ internal interface IFontFaceHandle internal interface IFontInstanceHandle { - - Texture? GetCharTexture(Rune codePoint, float scale); Texture? GetCharTexture(char chr, float scale) => GetCharTexture((Rune) chr, scale); CharMetrics? GetCharMetrics(Rune codePoint, float scale); @@ -35,6 +35,12 @@ internal interface IFontInstanceHandle int GetLineHeight(float scale); } + internal unsafe interface IFontMemoryHandle : IDisposable + { + byte* GetData(); + nint GetDataSize(); + } + /// /// Metrics for a single glyph in a font. /// Refer to https://www.freetype.org/freetype2/docs/glyphs/glyphs-3.html for more information. From 5966ad7457b69f846fa01ce6b01535a3d52b6b3e Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Aug 2024 23:43:30 +0200 Subject: [PATCH 3/5] Use ArrayPool to reduce char array allocations --- .../SystemFontManagerDirectWrite.cs | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs index a48468586d2..dec65df444e 100644 --- a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -348,36 +349,51 @@ private static DWriteLocalizedString[] GetStrings(IDWriteStringList* stringList) { var array = new DWriteLocalizedString[stringList->GetCount()]; + var stringPool = ArrayPool.Shared.Rent(256); + for (var i = 0; i < array.Length; i++) { uint length; ThrowIfFailed(stringList->GetStringLength((uint)i, &length), "IDWriteStringList::GetStringLength"); - var arr = new char[length + 1]; - fixed (char* pArr = arr) + ExpandIfNecessary(ref stringPool, length + 1); + fixed (char* pArr = stringPool) { - ThrowIfFailed(stringList->GetString((uint)i, pArr, (uint)arr.Length), "IDWriteStringList::GetString"); + ThrowIfFailed( + stringList->GetString((uint)i, pArr, (uint)stringPool.Length), + "IDWriteStringList::GetString"); } - var value = new string(arr, 0, (int)length); + var value = new string(stringPool, 0, (int)length); ThrowIfFailed(stringList->GetLocaleNameLength((uint)i, &length), "IDWriteStringList::GetLocaleNameLength"); - arr = new char[length + 1]; - fixed (char* pArr = arr) + ExpandIfNecessary(ref stringPool, length + 1); + fixed (char* pArr = stringPool) { ThrowIfFailed( - stringList->GetLocaleName((uint)i, pArr, (uint)arr.Length), + stringList->GetLocaleName((uint)i, pArr, (uint)stringPool.Length), "IDWriteStringList::GetLocaleName"); } - var localeName = new string(arr, 0, (int)length); + var localeName = new string(stringPool, 0, (int)length); array[i] = new DWriteLocalizedString(value, localeName); } + ArrayPool.Shared.Return(stringPool); + return array; } + private static void ExpandIfNecessary(ref char[] array, uint requiredLength) + { + if (requiredLength < array.Length) + return; + + ArrayPool.Shared.Return(array); + array = ArrayPool.Shared.Rent(checked((int)requiredLength)); + } + private static LocalizedStringSet StringsToSet(DWriteLocalizedString[] strings) { var dict = new Dictionary(); From 88b906f2f413e872afa2291c8baeb46928b354ce Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Aug 2024 23:43:47 +0200 Subject: [PATCH 4/5] Disable verbose logging --- Robust.Client/Graphics/SystemFontManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Robust.Client/Graphics/SystemFontManager.cs b/Robust.Client/Graphics/SystemFontManager.cs index c12f0aeb2e3..4797ac966ce 100644 --- a/Robust.Client/Graphics/SystemFontManager.cs +++ b/Robust.Client/Graphics/SystemFontManager.cs @@ -77,6 +77,6 @@ private ISystemFontManagerInternal GetImplementation() void IPostInjectInit.PostInject() { _sawmill = _logManager.GetSawmill("font.system"); - _sawmill.Level = LogLevel.Verbose; + // _sawmill.Level = LogLevel.Verbose; } } From 403fc404f38acae392780d71b2f3055827b18bdf Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Mon, 27 Oct 2025 10:43:42 +0100 Subject: [PATCH 5/5] Implement system font support on Linux via Fontconfig --- Directory.Packages.props | 1 + .../FontManagement/SystemFontManagerBase.cs | 165 +++++++++++++ .../SystemFontManagerDirectWrite.cs | 171 ++----------- .../SystemFontManagerFontconfig.cs | 233 ++++++++++++++++++ Robust.Client/Graphics/SystemFontManager.cs | 3 + Robust.Client/Robust.Client.csproj | 1 + 6 files changed, 422 insertions(+), 152 deletions(-) create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontManagerBase.cs create mode 100644 Robust.Client/Graphics/FontManagement/SystemFontManagerFontconfig.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index addbf98dd53..019d467378e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,6 +62,7 @@ + diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerBase.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerBase.cs new file mode 100644 index 00000000000..f27d5c37a05 --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerBase.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Threading; +using Robust.Shared.Log; + +namespace Robust.Client.Graphics.FontManagement; + +internal abstract class SystemFontManagerBase +{ + /// + /// The "standard" locale used when looking up the PostScript name of a font face. + /// + /// + /// + /// Font files allow the PostScript name to be localized, however in practice + /// we would really like to have a language-unambiguous identifier to refer to a font file. + /// We use this locale (en-US) to look up teh PostScript font name, if there are multiple provided. + /// This matches the behavior of the Local Font Access web API: + /// https://wicg.github.io/local-font-access/#concept-font-representation + /// + /// + protected static readonly CultureInfo StandardLocale = new("en-US", false); + + protected readonly IFontManagerInternal FontManager; + protected readonly ISawmill Sawmill; + + protected readonly Lock Lock = new(); + protected readonly List Fonts = []; + + public IEnumerable SystemFontFaces { get; } + + public SystemFontManagerBase(ILogManager logManager, IFontManagerInternal fontManager) + { + FontManager = fontManager; + Sawmill = logManager.GetSawmill("font.system"); + + SystemFontFaces = Fonts.AsReadOnly(); + } + + protected abstract IFontFaceHandle LoadFontFace(BaseHandle handle); + + protected static string GetLocalizedForLocaleOrFirst(LocalizedStringSet set, CultureInfo culture) + { + var matchCulture = culture; + while (!Equals(matchCulture, CultureInfo.InvariantCulture)) + { + if (set.Values.TryGetValue(culture.Name, out var value)) + return value; + + matchCulture = matchCulture.Parent; + } + + return set.Values[set.Primary]; + } + + protected abstract class BaseHandle(SystemFontManagerBase parent) : ISystemFontFace + { + private IFontFaceHandle? _cachedFont; + + public required string PostscriptName { get; init; } + + public required LocalizedStringSet FullNames; + public required LocalizedStringSet FamilyNames; + public required LocalizedStringSet FaceNames; + + public required FontWeight Weight { get; init; } + public required FontSlant Slant { get; init; } + public required FontWidth Width { get; init; } + + public string FullName => GetLocalizedFullName(CultureInfo.CurrentCulture); + public string FamilyName => GetLocalizedFamilyName(CultureInfo.CurrentCulture); + public string FaceName => GetLocalizedFaceName(CultureInfo.CurrentCulture); + + public string GetLocalizedFullName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FullNames, culture); + } + + public string GetLocalizedFamilyName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FamilyNames, culture); + } + + public string GetLocalizedFaceName(CultureInfo culture) + { + return GetLocalizedForLocaleOrFirst(FaceNames, culture); + } + + public Font Load(int size) + { + var handle = GetFaceHandle(); + + var instance = parent.FontManager.MakeInstance(handle, size); + + return new VectorFont(instance, size); + } + + private IFontFaceHandle GetFaceHandle() + { + lock (parent.Lock) + { + if (_cachedFont != null) + return _cachedFont; + + parent.Sawmill.Verbose($"Loading system font face: {PostscriptName}"); + + return _cachedFont = parent.LoadFontFace(this); + } + } + } + + protected struct LocalizedStringSet + { + public static readonly LocalizedStringSet Empty = new() + { + Primary = "en", + Values = new Dictionary { { "en", "" } } + }; + + /// + /// The first locale to appear in the list of localized strings. + /// Used as fallback if the desired locale is not provided. + /// + public required string Primary; + public required Dictionary Values; + } + + protected sealed class MemoryMappedFontMemoryHandle : IFontMemoryHandle + { + private readonly MemoryMappedFile _mappedFile; + private readonly MemoryMappedViewAccessor _accessor; + + public MemoryMappedFontMemoryHandle(string filePath) + { + _mappedFile = MemoryMappedFile.CreateFromFile( + filePath, + FileMode.Open, + null, + 0, + MemoryMappedFileAccess.Read); + + _accessor = _mappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); + } + + public unsafe byte* GetData() + { + byte* pointer = null; + _accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref pointer); + return pointer; + } + + public nint GetDataSize() + { + return (nint)_accessor.Capacity; + } + + public void Dispose() + { + _accessor.Dispose(); + _mappedFile.Dispose(); + } + } +} diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs index dec65df444e..4f6049f4c6e 100644 --- a/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs @@ -4,7 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; -using System.IO.MemoryMappedFiles; +using System.Reflection.Metadata; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.Log; @@ -20,38 +20,18 @@ namespace Robust.Client.Graphics.FontManagement; /// /// Implementation of that uses DirectWrite on Windows. /// -internal sealed unsafe class SystemFontManagerDirectWrite : ISystemFontManagerInternal +internal sealed unsafe class SystemFontManagerDirectWrite : SystemFontManagerBase, ISystemFontManagerInternal { // For future implementors of other platforms: // a significant amount of code in this file will be shareable with that of other platforms, // so some refactoring is warranted. - /// - /// The "standard" locale used when looking up the PostScript name of a font face. - /// - /// - /// - /// Font files allow the PostScript name to be localized, however in practice - /// we would really like to have a language-unambiguous identifier to refer to a font file. - /// We use this locale (en-US) to look up teh PostScript font name, if there are multiple provided. - /// This matches the behavior of the Local Font Access web API: - /// https://wicg.github.io/local-font-access/#concept-font-representation - /// - /// - private static readonly CultureInfo StandardLocale = new("en-US", false); - private readonly IConfigurationManager _cfg; - private readonly IFontManagerInternal _fontManager; - private readonly ISawmill _sawmill; - - private readonly object _lock = new(); - private readonly List _fonts = []; private IDWriteFactory3* _dWriteFactory; private IDWriteFontSet* _systemFontSet; public bool IsSupported => true; - public IEnumerable SystemFontFaces { get; } /// /// Implementation of that uses DirectWrite on Windows. @@ -60,12 +40,9 @@ public SystemFontManagerDirectWrite( ILogManager logManager, IConfigurationManager cfg, IFontManagerInternal fontManager) + : base(logManager, fontManager) { _cfg = cfg; - _fontManager = fontManager; - _sawmill = logManager.GetSawmill("font.system"); - - SystemFontFaces = _fonts.AsReadOnly(); } public void Initialize() @@ -74,7 +51,7 @@ public void Initialize() _systemFontSet = GetSystemFontSet(_dWriteFactory); - lock (_lock) + lock (Lock) { var fontCount = _systemFontSet->GetFontCount(); for (var i = 0u; i < fontCount; i++) @@ -83,7 +60,7 @@ public void Initialize() } } - _sawmill.Verbose($"Loaded {_fonts.Count} fonts"); + Sawmill.Verbose($"Loaded {Fonts.Count} fonts"); } public void Shutdown() @@ -94,14 +71,14 @@ public void Shutdown() _dWriteFactory->Release(); _dWriteFactory = null; - lock (_lock) + lock (Lock) { - foreach (var systemFont in _fonts) + foreach (var systemFont in Fonts) { - systemFont.FontFace->Release(); + ((Handle)systemFont).FontFace->Release(); } - _fonts.Clear(); + Fonts.Clear(); } } @@ -144,7 +121,7 @@ private void LoadSingleFontFromSet(IDWriteFontSet* set, uint fontIndex) Width = parsedWidth }; - _fonts.Add(handle); + Fonts.Add(handle); } private static FontWeight ParseFontWeight(DWriteLocalizedString[]? strings) @@ -191,7 +168,7 @@ private void CreateDWriteFactory() var result = factory->QueryInterface(__uuidof(), (void**)&factory6); if (result.SUCCEEDED) { - _sawmill.Verbose("IDWriteFactory6 available, using newer GetSystemFontSet"); + Sawmill.Verbose("IDWriteFactory6 available, using newer GetSystemFontSet"); result = factory6->GetSystemFontSet( _cfg.GetCVar(CVars.FontWindowsDownloadable), @@ -201,7 +178,7 @@ private void CreateDWriteFactory() } else { - _sawmill.Verbose("IDWriteFactory6 not available"); + Sawmill.Verbose("IDWriteFactory6 not available"); result = factory->GetSystemFontSet(&fontSet); } @@ -210,8 +187,9 @@ private void CreateDWriteFactory() return fontSet; } - private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) + protected override IFontFaceHandle LoadFontFace(BaseHandle handle) { + var fontFace = ((Handle)handle).FontFace; IDWriteFontFile* file = null; IDWriteFontFileLoader* loader = null; @@ -231,7 +209,7 @@ private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) result = loader->QueryInterface(__uuidof(), (void**)&localLoader); if (result.SUCCEEDED) { - _sawmill.Verbose("Loading font face via memory mapped file..."); + Sawmill.Verbose("Loading font face via memory mapped file..."); // We can get the local file path on disk. This means we can directly load it via mmap. uint filePathLength; @@ -254,11 +232,11 @@ private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) localLoader->Release(); - return _fontManager.Load(new MemoryMappedFontMemoryHandle(path)); + return FontManager.Load(new MemoryMappedFontMemoryHandle(path)); } else { - _sawmill.Verbose("Loading font face via stream..."); + Sawmill.Verbose("Loading font face via stream..."); // DirectWrite doesn't give us anything to go with for this file, read it into regular memory. // If the font file has multiple faces, which is possible, then this approach will duplicate memory. @@ -270,7 +248,7 @@ private IFontFaceHandle LoadFontFace(IDWriteFontFaceReference* fontFace) ThrowIfFailed(result, "IDWriteFontFileLoader::CreateStreamFromKey"); using var streamObject = new DirectWriteStream(stream); - return _fontManager.Load(streamObject, (int)fontFace->GetFontFaceIndex()); + return FontManager.Load(streamObject, (int)fontFace->GetFontFaceIndex()); } } finally @@ -406,74 +384,9 @@ private static LocalizedStringSet StringsToSet(DWriteLocalizedString[] strings) return new LocalizedStringSet { Primary = strings[0].LocaleName, Values = dict }; } - private static string GetLocalizedForLocaleOrFirst(LocalizedStringSet set, CultureInfo culture) - { - var matchCulture = culture; - while (!Equals(matchCulture, CultureInfo.InvariantCulture)) - { - if (set.Values.TryGetValue(culture.Name, out var value)) - return value; - - matchCulture = matchCulture.Parent; - } - - return set.Values[set.Primary]; - } - - private sealed class Handle(SystemFontManagerDirectWrite parent, IDWriteFontFaceReference* fontFace) : ISystemFontFace + private sealed class Handle(SystemFontManagerDirectWrite parent, IDWriteFontFaceReference* fontFace) : BaseHandle(parent) { - private IFontFaceHandle? _cachedFont; - - public required LocalizedStringSet FullNames; - public required LocalizedStringSet FamilyNames; - public required LocalizedStringSet FaceNames; public readonly IDWriteFontFaceReference* FontFace = fontFace; - - public required string PostscriptName { get; init; } - public string FullName => GetLocalizedFullName(CultureInfo.CurrentCulture); - public string FamilyName => GetLocalizedFamilyName(CultureInfo.CurrentCulture); - public string FaceName => GetLocalizedFaceName(CultureInfo.CurrentCulture); - - public string GetLocalizedFullName(CultureInfo culture) - { - return GetLocalizedForLocaleOrFirst(FullNames, culture); - } - - public string GetLocalizedFamilyName(CultureInfo culture) - { - return GetLocalizedForLocaleOrFirst(FamilyNames, culture); - } - - public string GetLocalizedFaceName(CultureInfo culture) - { - return GetLocalizedForLocaleOrFirst(FaceNames, culture); - } - - public required FontWeight Weight { get; init; } - public required FontSlant Slant { get; init; } - public required FontWidth Width { get; init; } - - public Font Load(int size) - { - var handle = GetFaceHandle(); - - var instance = parent._fontManager.MakeInstance(handle, size); - - return new VectorFont(instance, size); - } - - private IFontFaceHandle GetFaceHandle() - { - lock (parent._lock) - { - if (_cachedFont != null) - return _cachedFont; - - parent._sawmill.Verbose($"Loading system font face: {PostscriptName}"); - - return _cachedFont = parent.LoadFontFace(FontFace); - } - } } /// @@ -587,50 +500,4 @@ protected override void Dispose(bool disposing) } private record struct DWriteLocalizedString(string Value, string LocaleName); - - private struct LocalizedStringSet - { - /// - /// The first locale to appear in the list of localized strings. - /// Used as fallback if the desired locale is not provided. - /// - public required string Primary; - public required Dictionary Values; - } - - private sealed class MemoryMappedFontMemoryHandle : IFontMemoryHandle - { - private readonly MemoryMappedFile _mappedFile; - private readonly MemoryMappedViewAccessor _accessor; - - public MemoryMappedFontMemoryHandle(string filePath) - { - _mappedFile = MemoryMappedFile.CreateFromFile( - filePath, - FileMode.Open, - null, - 0, - MemoryMappedFileAccess.Read); - - _accessor = _mappedFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); - } - - public byte* GetData() - { - byte* pointer = null; - _accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref pointer); - return pointer; - } - - public nint GetDataSize() - { - return (nint)_accessor.Capacity; - } - - public void Dispose() - { - _accessor.Dispose(); - _mappedFile.Dispose(); - } - } } diff --git a/Robust.Client/Graphics/FontManagement/SystemFontManagerFontconfig.cs b/Robust.Client/Graphics/FontManagement/SystemFontManagerFontconfig.cs new file mode 100644 index 00000000000..6f4b1701248 --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerFontconfig.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Robust.Shared.Log; +using SpaceWizards.Fontconfig.Interop; + +namespace Robust.Client.Graphics.FontManagement; + +internal sealed unsafe class SystemFontManagerFontconfig : SystemFontManagerBase, ISystemFontManagerInternal +{ + private static readonly (int Fc, FontWidth Width)[] WidthTable = [ + (Fontconfig.FC_WIDTH_ULTRACONDENSED, FontWidth.UltraCondensed), + (Fontconfig.FC_WIDTH_EXTRACONDENSED, FontWidth.ExtraCondensed), + (Fontconfig.FC_WIDTH_CONDENSED, FontWidth.Condensed), + (Fontconfig.FC_WIDTH_SEMICONDENSED, FontWidth.SemiCondensed), + (Fontconfig.FC_WIDTH_NORMAL, FontWidth.Normal), + (Fontconfig.FC_WIDTH_SEMIEXPANDED, FontWidth.SemiExpanded), + (Fontconfig.FC_WIDTH_EXPANDED, FontWidth.Expanded), + (Fontconfig.FC_WIDTH_EXTRAEXPANDED, FontWidth.ExtraExpanded), + (Fontconfig.FC_WIDTH_ULTRAEXPANDED, FontWidth.UltraExpanded), + ]; + + public bool IsSupported => true; + + public SystemFontManagerFontconfig(ILogManager logManager, IFontManagerInternal fontManager) + : base(logManager, fontManager) + { + } + + public void Initialize() + { + Sawmill.Verbose("Initializing Fontconfig..."); + + var result = Fontconfig.FcInit(); + if (result == Fontconfig.FcFalse) + throw new InvalidOperationException("Failed to initialize fontconfig!"); + + Sawmill.Verbose("Listing fonts..."); + + var os = Fontconfig.FcObjectSetCreate(); + AddToObjectSet(os, Fontconfig.FC_FAMILY); + AddToObjectSet(os, Fontconfig.FC_FAMILYLANG); + AddToObjectSet(os, Fontconfig.FC_STYLE); + AddToObjectSet(os, Fontconfig.FC_STYLELANG); + AddToObjectSet(os, Fontconfig.FC_FULLNAME); + AddToObjectSet(os, Fontconfig.FC_FULLNAMELANG); + AddToObjectSet(os, Fontconfig.FC_POSTSCRIPT_NAME); + + AddToObjectSet(os, Fontconfig.FC_SLANT); + AddToObjectSet(os, Fontconfig.FC_WEIGHT); + AddToObjectSet(os, Fontconfig.FC_WIDTH); + + AddToObjectSet(os, Fontconfig.FC_FILE); + AddToObjectSet(os, Fontconfig.FC_INDEX); + + var allPattern = Fontconfig.FcPatternCreate(); + var set = Fontconfig.FcFontList(null, allPattern, os); + + for (var i = 0; i < set->nfont; i++) + { + var pattern = set->fonts[i]; + + try + { + LoadPattern(pattern); + } + catch (Exception e) + { + Sawmill.Error($"Error while loading pattern: {e}"); + } + } + + Fontconfig.FcPatternDestroy(allPattern); + Fontconfig.FcObjectSetDestroy(os); + Fontconfig.FcFontSetDestroy(set); + } + + public void Shutdown() + { + // Nada. + } + + private void LoadPattern(FcPattern* pattern) + { + var path = PatternGetStrings(pattern, Fontconfig.FC_FILE)![0]; + var idx = PatternGetInts(pattern, Fontconfig.FC_INDEX)![0]; + + var family = PatternToLocalized(pattern, Fontconfig.FC_FAMILY, Fontconfig.FC_FAMILYLANG); + var style = PatternToLocalized(pattern, Fontconfig.FC_STYLE, Fontconfig.FC_STYLELANG); + var fullName = PatternToLocalized(pattern, Fontconfig.FC_FULLNAME, Fontconfig.FC_FULLNAMELANG); + var psName = PatternGetStrings(pattern, Fontconfig.FC_POSTSCRIPT_NAME); + if (psName == null) + return; + + var slant = PatternGetInts(pattern, Fontconfig.FC_SLANT) ?? [Fontconfig.FC_SLANT_ROMAN]; + var weight = PatternGetInts(pattern, Fontconfig.FC_WEIGHT) ?? [Fontconfig.FC_WEIGHT_REGULAR]; + var width = PatternGetInts(pattern, Fontconfig.FC_WIDTH) ?? [Fontconfig.FC_WIDTH_NORMAL]; + + Fonts.Add(new Handle(this) + { + FilePath = path, + FileIndex = idx, + FaceNames = style ?? LocalizedStringSet.Empty, + FullNames = fullName ?? LocalizedStringSet.Empty, + FamilyNames = family ?? LocalizedStringSet.Empty, + PostscriptName = psName[0], + Slant = SlantFromFontconfig(slant[0]), + Weight = WeightFromFontconfig(weight[0]), + Width = WidthFromFontconfig(width[0]) + }); + } + + private static FontWeight WeightFromFontconfig(int value) + { + return (FontWeight)Fontconfig.FcWeightToOpenType(value); + } + + private static FontSlant SlantFromFontconfig(int value) + { + return value switch + { + Fontconfig.FC_SLANT_ITALIC => FontSlant.Italic, + Fontconfig.FC_SLANT_OBLIQUE => FontSlant.Italic, + _ => FontSlant.Normal, + }; + } + + private static FontWidth WidthFromFontconfig(int value) + { + return WidthTable.MinBy(t => Math.Abs(t.Fc - value)).Width; + } + + private static unsafe void AddToObjectSet(FcObjectSet* os, ReadOnlySpan value) + { + var result = Fontconfig.FcObjectSetAdd(os, (sbyte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(value))); + if (result == Fontconfig.FcFalse) + throw new InvalidOperationException("Failed to add to object set!"); + } + + private static unsafe string[]? PatternGetStrings(FcPattern* pattern, ReadOnlySpan @object) + { + return PatternGetValues(pattern, @object, static (FcPattern* p, sbyte* o, int i, out string value) => + { + byte* str = null; + var res = Fontconfig.FcPatternGetString(p, o, i, &str); + value = Marshal.PtrToStringUTF8((nint)str)!; + return res; + }); + } + + private static unsafe int[]? PatternGetInts(FcPattern* pattern, ReadOnlySpan @object) + { + return PatternGetValues(pattern, @object, static (FcPattern* p, sbyte* o, int i, out int value) => + { + FcResult res; + fixed (int* pValue = &value) + { + res = Fontconfig.FcPatternGetInteger(p, o, i, pValue); + } + return res; + }); + } + + private delegate FcResult GetValue(FcPattern* p, sbyte* o, int i, out T value); + private static unsafe T[]? PatternGetValues(FcPattern* pattern, ReadOnlySpan @object, GetValue getValue) + { + var list = new List(); + + var i = 0; + while (true) + { + var result = getValue(pattern, (sbyte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(@object)), i++, out var value); + if (result == FcResult.FcResultMatch) + { + list.Add(value); + } + else if (result == FcResult.FcResultNoMatch) + { + return null; + } + else if (result == FcResult.FcResultNoId) + { + break; + } + else + { + throw new Exception($"FcPatternGetString gave error: {result}"); + } + } + + return list.ToArray(); + } + + private static LocalizedStringSet? PatternToLocalized(FcPattern* pattern, ReadOnlySpan @object, ReadOnlySpan objectLang) + { + var values = PatternGetStrings(pattern, @object); + var languages = PatternGetStrings(pattern, objectLang); + + if (values == null || languages == null || values.Length == 0 || languages.Length != values.Length) + return null; + + var dict = new Dictionary(); + + for (var i = 0; i < values.Length; i++) + { + var val = values[i]; + var lang = languages[i]; + + dict.TryAdd(lang, val); + } + + return new LocalizedStringSet + { + Primary = languages[0], + Values = dict + }; + } + + protected override IFontFaceHandle LoadFontFace(BaseHandle handle) + { + var cast = (Handle)handle; + + return FontManager.Load(new MemoryMappedFontMemoryHandle(cast.FilePath), cast.FileIndex); + } + + private sealed class Handle(SystemFontManagerFontconfig parent) : BaseHandle(parent) + { + public required string FilePath; + public required int FileIndex; + } +} diff --git a/Robust.Client/Graphics/SystemFontManager.cs b/Robust.Client/Graphics/SystemFontManager.cs index 4797ac966ce..939638df2d4 100644 --- a/Robust.Client/Graphics/SystemFontManager.cs +++ b/Robust.Client/Graphics/SystemFontManager.cs @@ -69,6 +69,9 @@ private ISystemFontManagerInternal GetImplementation() if (OperatingSystem.IsWindows()) return new SystemFontManagerDirectWrite(_logManager, _cfg, _fontManager); + if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) + return new SystemFontManagerFontconfig(_logManager, _fontManager); + // TODO: Linux and macOS implementations. return new SystemFontManagerFallback(); diff --git a/Robust.Client/Robust.Client.csproj b/Robust.Client/Robust.Client.csproj index c1df156554f..7f8f3d3649a 100644 --- a/Robust.Client/Robust.Client.csproj +++ b/Robust.Client/Robust.Client.csproj @@ -27,6 +27,7 @@ +