using HarmonyLib; using OWML.Common; using OWML.ModHelper; using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.UI; namespace SlovakTranslation { /// /// 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: /// /// Adobe - SerifGothicStd (+ExtraBold) menus 253 glyphs — no carons /// Gill Sans MT (+Menu, Bold) HUD prompts 243 glyphs — no carons /// ParaType - Futura PT Medium dialogue 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 it also flips TextTranslation.IsLanguageLatin() to /// false, which drags dialogue, the translator and the ship log off their /// proper fonts. And UIStyleManager.GetMenuFont() — the obvious hook — /// is dead code with no callers; the fonts are assigned straight onto the /// components in the scenes. /// /// So we substitute at the Text level, but the decision is made **per font, not /// per label**: a font that cannot draw Slovak is replaced everywhere it is /// used, so a menu never mixes two typefaces. Fonts that can draw Slovak — /// the dialogue and translator fonts — are left completely alone. /// /// The replacement is preferably the game's *own* dynamic cut of the same /// typeface (it ships several, e.g. Adobe - SerifGothicStd_Dynamic), which keeps /// the menu pixel-identical to vanilla. Bundled Cabin is the fallback. /// internal static class SlovakFont { /// Cabin (SIL OFL 1.1), a humanist sans in the Gill Sans lineage, used /// where the game has no dynamic cut of its own. Dynamic, so it can draw any /// glyph Slovak needs. private static Font _font; /// The letters Slovak adds on top of plain ASCII. Only these are worth /// probing — anything else in the text was already fine in English. private const string SlovakLetters = "áäčďéíĺľňóôŕšťúýžÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ"; /// Stylised display faces (the ship's readouts, seven-segment digits). /// Replacing these wholesale would cost more in looks than it gains, so they /// are only substituted on the individual labels that actually need a glyph. private static readonly string[] Stylised = { "VCR_OSD_MONO", "digital-7", "PCBius" }; /// Font instance ID → the Slovak letters that font cannot draw. private static readonly Dictionary _gaps = new Dictionary(); /// Labels we substituted, and the font the game wanted on them. private static readonly Dictionary _swapped = new Dictionary(); /// Original font instance ID → what we put in its place. private static readonly Dictionary _substitutes = new Dictionary(); /// The game's own dynamic fonts, which can draw glyphs their static /// siblings were never baked with. private static Font[] _gameDynamicFonts = new Font[0]; /// When set, a static font is replaced by the game's own dynamic cut of the /// same typeface where one exists, keeping the vanilla look. Cabin otherwise. internal static bool PreferGameTypeface = true; private static ModBehaviour _mod; internal static bool Loaded => _font != null; internal static void Load(ModBehaviour mod) { _mod = 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()) { if (font.name != "SlovakUI") continue; _font = font; Object.DontDestroyOnLoad(font); break; } if (_font == null) mod.ModHelper.Console.WriteLine("Font bundle has no 'SlovakUI' font.", MessageType.Error); } /// Give a font that can draw Slovak, or hand back /// the game's own font once it is no longer needed. internal static void Apply(Text text) { if (_font == null || text == null) return; var current = text.font; Font stored; var swapped = _swapped.TryGetValue(text, out stored) && stored != null && current == SubstituteFor(stored); // If anything else assigned a font since our swap, that is the new baseline. var wanted = swapped ? stored : current; if (!swapped) _swapped.Remove(text); if (wanted == null) return; var needsHelp = NeedsSubstitute(wanted, text.text); if (needsHelp == swapped) return; if (needsHelp) { _swapped[text] = wanted; text.font = SubstituteFor(wanted); } else { _swapped.Remove(text); text.font = wanted; } } /// What to draw 's labels with instead. The /// game ships dynamic cuts of several of its typefaces; borrowing the matching one /// keeps the menu looking exactly like vanilla, which no substitute font can. private static Font SubstituteFor(Font original) { Font cached; if (_substitutes.TryGetValue(original.GetInstanceID(), out cached) && cached != null) return cached; if (!PreferGameTypeface) return _font; if (_gameDynamicFonts.Length == 0) RefreshGameFonts(); Font best = null; var bestLength = 0; foreach (var candidate in _gameDynamicFonts) { var baseName = candidate.name.EndsWith("_Dynamic") ? candidate.name.Substring(0, candidate.name.Length - "_Dynamic".Length) : candidate.name; if (baseName.Length <= bestLength) continue; if (!original.name.StartsWith(baseName, System.StringComparison.Ordinal)) continue; if (GapsOf(candidate).Length != 0) continue; best = candidate; bestLength = baseName.Length; } if (best == null) return _font; _substitutes[original.GetInstanceID()] = best; _mod?.ModHelper.Console.WriteLine( $"drawing \"{original.name}\" with the game's own \"{best.name}\"", MessageType.Info); return best; } private static void RefreshGameFonts() { var found = new List(); foreach (var font in Resources.FindObjectsOfTypeAll()) { if (font != null && font.dynamic && font != _font) found.Add(font); } _gameDynamicFonts = found.ToArray(); } /// Drop every cached decision, so a settings change takes effect at once. internal static void ForgetChoices() { _substitutes.Clear(); _gameDynamicFonts = new Font[0]; foreach (var pair in _swapped) { if (pair.Key != null && pair.Value != null) pair.Key.font = pair.Value; } _swapped.Clear(); } /// Re-check every live label. Scene loads and language changes reassign /// fonts wholesale, and menus are built lazily, so sweep periodically too. internal static void ApplyToAll() { if (_font == null) return; // Labels that have since been destroyed would otherwise pile up. var dead = new List(); foreach (var pair in _swapped) { if (pair.Key == null) dead.Add(pair.Key); } foreach (var text in dead) _swapped.Remove(text); RefreshGameFonts(); foreach (var text in Object.FindObjectsOfType()) Apply(text); } private static bool NeedsSubstitute(Font font, string content) { if (!CustomLanguageActive()) return false; var gaps = GapsOf(font); if (gaps.Length == 0) return false; // Ordinary UI faces are replaced everywhere they appear, so a menu never // mixes typefaces. Stylised readouts only give way when a glyph is at stake. if (!IsStylised(font.name)) return true; if (string.IsNullOrEmpty(content)) return false; foreach (var c in content) { if (gaps.IndexOf(c) >= 0) return true; } return false; } private static string GapsOf(Font font) { var id = font.GetInstanceID(); string gaps; if (_gaps.TryGetValue(id, out gaps)) return gaps; // Dynamic fonts only report a character as present once it has been // rasterised, so ask for the whole Slovak set before probing. if (font.dynamic) font.RequestCharactersInTexture(SlovakLetters); var builder = new StringBuilder(); foreach (var c in SlovakLetters) { if (!font.HasCharacter(c)) builder.Append(c); } gaps = builder.ToString(); _gaps[id] = gaps; if (gaps.Length > 0) { _mod?.ModHelper.Console.WriteLine( $"font \"{font.name}\" cannot draw \"{gaps}\" — " + (IsStylised(font.name) ? "substituting only where needed" : "substituting everywhere"), MessageType.Info); } return gaps; } private static bool IsStylised(string fontName) { foreach (var stylised in Stylised) { if (fontName.IndexOf(stylised, System.StringComparison.OrdinalIgnoreCase) >= 0) return true; } return false; } /// True while a mod-registered language is selected. The Localization /// Utility appends custom languages past Language.TOTAL, and treats /// everything up to it as vanilla — same test it uses itself. private static bool CustomLanguageActive() { if (TextTranslation.s_theTable == null) return false; return TextTranslation.Get().GetLanguage() > TextTranslation.Language.TOTAL; } } /// /// Hooks: every label as it appears or changes, plus a sweep whenever the game /// reassigns fonts wholesale. /// [HarmonyPatch] internal static class SlovakFontPatches { [HarmonyPostfix, HarmonyPatch(typeof(Text), "OnEnable")] public static void TextEnabled(Text __instance) => SlovakFont.Apply(__instance); [HarmonyPostfix, HarmonyPatch(typeof(Text), "text", MethodType.Setter)] public static void TextChanged(Text __instance) => SlovakFont.Apply(__instance); [HarmonyPostfix, HarmonyPatch(typeof(TextTranslation), nameof(TextTranslation.SetLanguage))] public static void LanguageChanged() => SlovakFont.ApplyToAll(); } }