using Dalamud; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Plugin; namespace HellionChat; // Two LogProxy sites live in static methods (TryGetHellionFontBytes, // AddFontWithFallback); a ctor-injected ILogger would not be reachable // from those scopes, so the class stays on Plugin.LogProxy. // // Hybrid handle model: Axis and AxisItalic mirror the game's current // font state and are init-only. FontAwesome reuses Dalamud's UiBuilder // fixed-width icon handle and is likewise init-only. RegularFont and // ItalicFont depend on user-toggleable settings and get replaced live // via RebuildDelegateFonts when those settings change; they stay as // mutable nullable fields. // // The four atlas-owned handles register inside a single // SuppressAutoRebuild block so the font atlas only rebuilds once for the // whole plugin start instead of once per handle. FontAwesome lives // outside that accounting because the UiBuilder already owns it. public sealed class FontManager : IDisposable { private readonly IDalamudPluginInterface _pluginInterface; internal IFontHandle Axis { get; init; } internal IFontHandle AxisItalic { get; init; } internal IFontHandle FontAwesome { get; init; } // Mutable because the live font settings replace these via // RebuildDelegateFonts. Reference replacement is atomic for reference // types, so push sites that read the field once per frame see at most // one stale handle. internal IFontHandle? RegularFont; internal IFontHandle? ItalicFont; private ushort[] Ranges = []; private ushort[] JpRange = []; public static readonly HashSet AxisFontSizeList = [ 9.6f, 10f, 12f, 14f, 16f, 18f, 18.4f, 20f, 23f, 34f, 36f, 40f, 45f, 46f, 68f, 90f, ]; // Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources private static byte[]? HellionFontBytes; public FontManager(IDalamudPluginInterface pluginInterface) { _pluginInterface = pluginInterface; SetUpRanges(); var atlas = _pluginInterface.UiBuilder.FontAtlas; using (atlas.SuppressAutoRebuild()) { Axis = atlas.NewGameFontHandle( new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) ); AxisItalic = atlas.NewGameFontHandle( new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) { SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6, } ); FontAwesome = _pluginInterface.UiBuilder.IconFontFixedWidthHandle; RegularFont = BuildRegularFontHandle(atlas); if (Plugin.Config.ItalicEnabled) ItalicFont = BuildItalicFontHandle(atlas); } } // Called from the settings save path when one of the font-related // settings changed. Game fonts and FontAwesome stay untouched because // none of those settings affect them. // // Thread model: the settings save path runs on the ImGui draw thread, // same as every push site. The rebuild finishes synchronously before // the next push reads the field in the same frame, so there is no // cross-thread race on the handle reference. public void RebuildDelegateFonts() { SetUpRanges(); var atlas = _pluginInterface.UiBuilder.FontAtlas; RegularFont?.Dispose(); RegularFont = BuildRegularFontHandle(atlas); ItalicFont?.Dispose(); ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; } // Instance method so Ranges / JpRange are reachable without parameter // plumbing; PascalCase field names follow the existing class style. private IFontHandle BuildRegularFontHandle(IFontAtlas atlas) => atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { // UseHellionFont swaps the source font but keeps the size // selector tied to FontSizeV2 (the Hellion font ships as // a single weight). var basePt = Plugin.Config.UseHellionFont ? Plugin.Config.FontSizeV2 : Plugin.Config.GlobalFontV2.SizePt; var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; // Missing embedded resource falls back to the configured // system font instead of taking the whole UiBuilder down. var hellionBytes = Plugin.Config.UseHellionFont ? TryGetHellionFontBytes() : null; config.MergeFont = hellionBytes is not null ? tk.AddFontFromMemory(hellionBytes, config, "Hellion-Exo2") : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global"); config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; config.GlyphRanges = JpRange; AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); config.SizePt = Plugin.Config.SymbolsFontSizeV2; tk.AddGameSymbol(config); tk.Font = config.MergeFont; }) ); private IFontHandle BuildItalicFontHandle(IFontAtlas atlas) => atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { var config = new SafeFontConfig { SizePt = Plugin.Config.ItalicFontV2.SizePt, GlyphRanges = Ranges, }; config.MergeFont = AddFontWithFallback( tk, Plugin.Config.ItalicFontV2.FontId, config, "italic" ); config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; config.GlyphRanges = JpRange; AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); config.SizePt = Plugin.Config.SymbolsFontSizeV2; tk.AddGameSymbol(config); tk.Font = config.MergeFont; }) ); public void Dispose() { Axis.Dispose(); AxisItalic.Dispose(); // FontAwesome is shared with the UiBuilder; the host owns its // lifetime, so the plugin must not dispose it. RegularFont?.Dispose(); ItalicFont?.Dispose(); } // Returns null when the embedded font resource is missing. Should not // happen on a signed release build, but a broken csproj or hand-rolled // dev build can land here. Caller falls back to the system font path // so the plugin still loads instead of crashing the whole UiBuilder. private static byte[]? TryGetHellionFontBytes() { if (HellionFontBytes is not null) return HellionFontBytes; using var stream = typeof(FontManager).Assembly.GetManifestResourceStream( "HellionFont.ttf" ); if (stream is null) { Plugin.LogProxy.Warning( "Hellion font resource missing — falling back to system default font." ); return null; } using var ms = new MemoryStream(); stream.CopyTo(ms); HellionFontBytes = ms.ToArray(); return HellionFontBytes; } private unsafe void SetUpRanges() { ushort[] BuildRange(IReadOnlyList? chars, params nint[] ranges) { var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); foreach (var range in ranges) builder.AddRanges((ushort*)range); if (chars != null) { for (var i = 0; i < chars.Count; i += 2) { if (chars[i] == 0) break; for (var j = (uint)chars[i]; j <= chars[i + 1]; j++) builder.AddChar((ushort)j); } } // Ingame supported ranges var reader = new FdtReader(Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data); foreach (var c in reader.Glyphs) builder.AddChar(c.Char); // French // Romanian builder.AddText("Œœ"); builder.AddText("ĂăÂâÎîȘșȚț"); // "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460 for (var i = 0x2460; i <= 0x24B5; i++) builder.AddChar((char)i); builder.AddChar('⓪'); return builder.BuildRangesToArray(); } var ranges = new List { (nint)ImGui.GetIO().Fonts.GetGlyphRangesDefault() }; foreach (var extraRange in Enum.GetValues()) if (Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange)) ranges.Add(extraRange.Range()); Ranges = BuildRange(null, ranges.ToArray()); JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); } // Add font with fallback to NotoSansCjkRegular if unavailable private static ImFontPtr AddFontWithFallback( IFontAtlasBuildToolkitPreBuild tk, IFontId fontId, SafeFontConfig config, string slot ) { try { return fontId.AddToBuildToolkit(tk, config); } catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException or IOException or InvalidOperationException or ArgumentException ) { // Atlas-toolkit throws span IO and validation failures; routing // the wider set through the fallback keeps a corrupt font config // from taking down the whole atlas build. Plugin.LogProxy.Warning( e, $"Configured {slot} font failed to load ({e.GetType().Name}), " + "falling back to NotoSansCjkRegular" ); var fallback = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular); return fallback.AddToBuildToolkit(tk, config); } } public static float SizeInPt(float px) => (float)(px * 3.0 / 4.0); public static float SizeInPx(float pt) => (float)(pt * 4.0 / 3.0); public static float GetFontSize() => Plugin.Config.FontsEnabled ? Plugin.Config.GlobalFontV2.SizePx : SizeInPx(Plugin.Config.FontSizeV2); }