Files
Outer-Wilds-SK-Translation-Mod/SlovakFont.cs
T
Jaroslav Beneš 645b8b070b Render Slovak carons in menus and HUD without disturbing the other text
The stock menu font (Gill Sans MT Menu) is a static 243-glyph atlas with no
Latin Extended-A, so "Pokračovať" rendered as "Pokra ova" and "Podrž" as
"Podr". Dialogue, the Nomai translator and the ship log were always fine —
they use Futura PT (663) and Space Mono (612), both of which cover Slovak.

Registering a language font via AddLanguageFont fixes the menus but flips
TextTranslation.IsLanguageLatin() to false, and the game uses that flag to
decide whether dialogue, the translator and the ship log keep their own fonts
or fall back to the language font. So the blanket fix dragged all three onto
the wrong font, oversized and clipping.

Instead: register no language font at all, and postfix only the font getters
(UIStyleManager, PromptManager, GameOverController). Each result goes through
SlovakFont.Fix, which probes the font with Font.HasCharacter and returns it
untouched unless it genuinely cannot draw Slovak — so nothing that already
renders correctly is ever replaced, and the patches are inert in every other
language.

The substitute is Cabin (SIL OFL 1.1), a humanist sans in the Gill Sans
lineage, shipped as a dynamic font with its vertical metrics rewritten to Gill
Sans MT's exact ratios so line height matches and text does not clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:02:56 +02:00

187 lines
7.9 KiB
C#

using HarmonyLib;
using OWML.Common;
using OWML.ModHelper;
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;
namespace SlovakTranslation
{
/// <summary>
/// Fixes the missing Slovak caron glyphs (č ď ĺ ľ ň ŕ ť ž) in the menus and HUD.
///
/// Outer Wilds renders Latin languages with a handful of *static* font atlases,
/// and only some of them were baked with Latin Extended-A:
///
/// Gill Sans MT / Gill Sans MT Menu (menus, prompts) 243 glyphs — no carons
/// ParaType - Futura PT Medium (language font) 663 glyphs — complete
/// SpaceMono-Regular (translator) 612 glyphs — complete
///
/// That is why dialogue and the Nomai translator always looked right while
/// "Pokračovať" came out as "Pokra ova". Registering a whole custom language
/// font via the Localization Utility fixes the menus but also flips
/// <c>TextTranslation.IsLanguageLatin()</c> to false, which drags dialogue,
/// the translator and the ship log off their proper fonts too.
///
/// So instead we leave the language font alone and swap fonts *only* where the
/// vanilla one physically cannot draw Slovak — checked at runtime with
/// <c>Font.HasCharacter</c>, so we never touch a font that renders fine.
/// </summary>
internal static class SlovakFont
{
/// <summary>Cabin (SIL OFL 1.1), a humanist sans close to the vanilla Gill Sans MT.
/// Shipped as a dynamic font, so it can draw any glyph Slovak needs.</summary>
private static Font _font;
/// <summary>Characters the vanilla menu atlas is missing. If a font can draw all
/// of these it is left completely alone.</summary>
private static readonly char[] Probe = { 'č', 'ď', 'ĺ', 'ľ', 'ň', 'ŕ', 'ť', 'ž', 'ô', 'ä' };
private static readonly Dictionary<int, bool> _coverage = new Dictionary<int, bool>();
internal static bool Loaded => _font != null;
internal static void Load(ModBehaviour mod)
{
var path = mod.ModHelper.Manifest.ModFolderPath + "assets/slovakfont";
var bundle = AssetBundle.LoadFromFile(path);
if (bundle == null)
{
mod.ModHelper.Console.WriteLine("Could not load font bundle at " + path, MessageType.Error);
return;
}
foreach (var font in bundle.LoadAllAssets<Font>())
{
if (font.name != "SlovakUI") continue;
_font = font;
UnityEngine.Object.DontDestroyOnLoad(font);
break;
}
if (_font == null)
mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error);
}
/// <summary>Returns <paramref name="font"/> unchanged unless Slovenčina is the
/// active language and that font cannot draw Slovak.</summary>
internal static Font Fix(Font font)
{
if (_font == null || font == null || font == _font) return font;
// Dynamic fonts rasterise straight from an embedded font file; every one the
// game ships for Latin covers Slovak, and HasCharacter is unreliable for them.
if (font.dynamic) return font;
if (!IsActive()) return font;
var id = font.GetInstanceID();
if (!_coverage.TryGetValue(id, out var complete))
{
complete = true;
foreach (var c in Probe)
{
if (font.HasCharacter(c)) continue;
complete = false;
break;
}
_coverage[id] = complete;
}
return complete ? font : _font;
}
#region "is Slovenčina selected?"
// The Localization Utility invents a TextTranslation.Language value for each
// custom language, so it is not in the enum at compile time. Ask the utility
// once, then cache the value and compare enums from then on.
private static bool _resolved;
private static TextTranslation.Language _slovak;
private static MethodInfo _tryGetLanguage;
private static object _utility;
private static bool IsActive()
{
if (TextTranslation.s_theTable == null) return false;
var current = TextTranslation.Get().GetLanguage();
if (_resolved) return current == _slovak;
if (_utility == null)
{
var type = SlovakTranslation.LocalizationApi?.GetType().Assembly.GetType("LocalizationUtility.LocalizationUtility");
if (type == null) return false;
_utility = type.GetField("Instance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
foreach (var m in type.GetMethods())
{
var p = m.GetParameters();
if (m.Name == "TryGetLanguage" && p.Length == 2 &&
p[0].ParameterType == typeof(TextTranslation.Language))
{
_tryGetLanguage = m;
break;
}
}
if (_utility == null || _tryGetLanguage == null) return false;
}
var args = new object[] { current, null };
if (!(bool)_tryGetLanguage.Invoke(_utility, args) || args[1] == null) return false;
var name = args[1].GetType().GetProperty("Name")?.GetValue(args[1], null) as string;
if (name != SlovakTranslation.LanguageName) return false;
_slovak = current;
_resolved = true;
return true;
}
#endregion
}
/// <summary>
/// Every place the game hands out a font that is not the language font. Each one
/// runs through <see cref="SlovakFont.Fix"/>, which is a no-op unless the font
/// really is missing Slovak glyphs.
/// </summary>
[HarmonyPatch]
internal static class SlovakFontPatches
{
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetMenuFont))]
public static void MenuFont(ref Font __result) => __result = SlovakFont.Fix(__result);
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetTranslatorFont))]
public static void TranslatorFont(ref Font __result) => __result = SlovakFont.Fix(__result);
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetShipLogFont))]
public static void ShipLogFont(ref Font __result) => __result = SlovakFont.Fix(__result);
[HarmonyPostfix, HarmonyPatch(typeof(UIStyleManager), nameof(UIStyleManager.GetShipLogCardFont))]
public static void ShipLogCardFont(ref Font __result) => __result = SlovakFont.Fix(__result);
// Prompts ("Podrž", "Pokračovať"…) cache their font in a private field and then
// rebuild, so patch after the fact and rebuild once more.
[HarmonyPostfix, HarmonyPatch(typeof(PromptManager), "OnLanguageChanged")]
public static void PromptFont(PromptManager __instance)
{
var field = AccessTools.Field(typeof(PromptManager), "_currentFont");
var current = field.GetValue(__instance) as Font;
var fixedFont = SlovakFont.Fix(current);
if (fixedFont == current) return;
field.SetValue(__instance, fixedFont);
AccessTools.Method(typeof(PromptManager), "RebuildUI")?.Invoke(__instance, null);
}
[HarmonyPostfix, HarmonyPatch(typeof(GameOverController), nameof(GameOverController.SetupGameOverScreen))]
public static void DeathFont(GameOverController __instance)
{
var text = AccessTools.Field(typeof(GameOverController), "_deathText").GetValue(__instance) as Text;
if (text != null) text.font = SlovakFont.Fix(text.font);
}
}
}