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/RELEASE-NOTES.md b/RELEASE-NOTES.md index 05aee74aa29..2adb16a2624 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -46,6 +46,9 @@ END TEMPLATE--> * Sandbox: * Exposed `System.Reflection.Metadata.MetadataUpdateHandlerAttribute`. * Exposed more overloads on `StringBuilder`. +* 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 bb83231862a..15d25740fdc 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.HWId; using Robust.Client.Input; using Robust.Client.Localization; @@ -121,6 +122,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(); @@ -131,6 +134,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 301e0598745..5591f588124 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -96,6 +96,7 @@ internal sealed partial class GameController : IGameControllerInternal [Dependency] private readonly IReflectionManager _reflectionManager = default!; [Dependency] private readonly IReloadManager _reload = default!; [Dependency] private readonly ILocalizationManager _loc = default!; + [Dependency] private readonly ISystemFontManagerInternal _systemFontManager = default!; private IWebViewManagerHook? _webViewHook; @@ -143,6 +144,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 da627769c2e..e1f16b92f27 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); @@ -222,4 +228,74 @@ public override float DrawChar(DrawingHandleBase handle, Rune rune, Vector2 base 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/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 new file mode 100644 index 00000000000..4f6049f4c6e --- /dev/null +++ b/Robust.Client/Graphics/FontManagement/SystemFontManagerDirectWrite.cs @@ -0,0 +1,503 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Reflection.Metadata; +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 : 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. + + private readonly IConfigurationManager _cfg; + + private IDWriteFactory3* _dWriteFactory; + private IDWriteFontSet* _systemFontSet; + + public bool IsSupported => true; + + /// + /// Implementation of that uses DirectWrite on Windows. + /// + public SystemFontManagerDirectWrite( + ILogManager logManager, + IConfigurationManager cfg, + IFontManagerInternal fontManager) + : base(logManager, fontManager) + { + _cfg = cfg; + } + + 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) + { + ((Handle)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; + } + + protected override IFontFaceHandle LoadFontFace(BaseHandle handle) + { + var fontFace = ((Handle)handle).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"); + + IDWriteLocalFontFileLoader* localLoader; + result = loader->QueryInterface(__uuidof(), (void**)&localLoader); + if (result.SUCCEEDED) + { + 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; + 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 + { + 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()); + } + } + 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()]; + + var stringPool = ArrayPool.Shared.Rent(256); + + for (var i = 0; i < array.Length; i++) + { + uint length; + + ThrowIfFailed(stringList->GetStringLength((uint)i, &length), "IDWriteStringList::GetStringLength"); + ExpandIfNecessary(ref stringPool, length + 1); + fixed (char* pArr = stringPool) + { + ThrowIfFailed( + stringList->GetString((uint)i, pArr, (uint)stringPool.Length), + "IDWriteStringList::GetString"); + } + + var value = new string(stringPool, 0, (int)length); + + ThrowIfFailed(stringList->GetLocaleNameLength((uint)i, &length), "IDWriteStringList::GetLocaleNameLength"); + ExpandIfNecessary(ref stringPool, length + 1); + fixed (char* pArr = stringPool) + { + ThrowIfFailed( + stringList->GetLocaleName((uint)i, pArr, (uint)stringPool.Length), + "IDWriteStringList::GetLocaleName"); + } + + 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(); + + foreach (var (value, localeName) in strings) + { + dict[localeName] = value; + } + + return new LocalizedStringSet { Primary = strings[0].LocaleName, Values = dict }; + } + + private sealed class Handle(SystemFontManagerDirectWrite parent, IDWriteFontFaceReference* fontFace) : BaseHandle(parent) + { + public readonly IDWriteFontFaceReference* FontFace = 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); +} 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/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/FontManager.cs b/Robust.Client/Graphics/FontManager.cs index 19c0d410a9f..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 { @@ -34,13 +33,18 @@ 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 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 34cacb93dc0..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; @@ -10,7 +11,8 @@ public interface IFontManager } internal interface IFontManagerInternal : IFontManager { - IFontFaceHandle Load(Stream stream); + 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. 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..939638df2d4 --- /dev/null +++ b/Robust.Client/Graphics/SystemFontManager.cs @@ -0,0 +1,85 @@ +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); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) + return new SystemFontManagerFontconfig(_logManager, _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.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 @@ + diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 1996c21ca09..07007de3318 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1924,5 +1924,15 @@ internal static readonly CVarDef /// public static readonly CVarDef XamlHotReloadMarkerName = CVarDef.Create("ui.xaml_hot_reload_marker_name", "SpaceStation14.sln", CVar.CLIENTONLY); + + /* + * 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); } }