diff --git a/indra/llrender/llfontfreetype.cpp b/indra/llrender/llfontfreetype.cpp index 0eab158ff1..674f95e34b 100644 --- a/indra/llrender/llfontfreetype.cpp +++ b/indra/llrender/llfontfreetype.cpp @@ -462,6 +462,46 @@ S32 LLFontFreetype::getNumFaces(const std::string& filename) return num_faces; } +LLFontFreetype::FaceInfo LLFontFreetype::probeFaceInfo(const std::string& filename) +{ + FaceInfo info; + + auto contents = LLFile::getContents(filename); + if (contents.empty()) + return info; + + FT_Open_Args openArgs; + memset(&openArgs, 0, sizeof(openArgs)); + openArgs.memory_base = reinterpret_cast(contents.data()); + openArgs.memory_size = static_cast(contents.size()); + openArgs.flags = FT_OPEN_MEMORY; + + FT_Face probe = nullptr; + if (FT_Open_Face(gFTLibrary, &openArgs, 0, &probe) != 0) + return info; + + info.family = probe->family_name ? probe->family_name : ""; + + info.bold = (probe->style_flags & FT_STYLE_FLAG_BOLD) != 0; + info.italic = (probe->style_flags & FT_STYLE_FLAG_ITALIC) != 0; + if (!info.bold || !info.italic) + { + std::string style = probe->style_name ? probe->style_name : ""; + LLStringUtil::toLower(style); + if (style.find("bold") != std::string::npos) + info.bold = true; + if (style.find("italic") != std::string::npos || style.find("oblique") != std::string::npos) + info.italic = true; + } + + info.fixed_width = (probe->face_flags & FT_FACE_FLAG_FIXED_WIDTH) != 0; + info.has_color = FT_HAS_COLOR(probe); + info.valid = !info.family.empty(); + + FT_Done_Face(probe); + return info; +} + void LLFontFreetype::addFallbackFont(const LLPointer& fallback_font, const char_functor_t& functor) const { diff --git a/indra/llrender/llfontfreetype.h b/indra/llrender/llfontfreetype.h index 3279c6a0bf..9ff7bf7ed5 100644 --- a/indra/llrender/llfontfreetype.h +++ b/indra/llrender/llfontfreetype.h @@ -158,6 +158,18 @@ class LLFontFreetype : public LLRefCount // touches no instance state, so callers don't need a live freetype. static S32 getNumFaces(const std::string& filename); + struct FaceInfo + { + bool valid = false; + std::string family; + bool bold = false; + bool italic = false; + bool fixed_width = false; + bool has_color = false; + }; + // Probe the first face of a font file. + static FaceInfo probeFaceInfo(const std::string& filename); + typedef std::function char_functor_t; void addFallbackFont(const LLPointer& fallback_font, const char_functor_t& functor = nullptr) const; typedef std::pair, char_functor_t> fallback_font_t; diff --git a/indra/llrender/llfontgl.cpp b/indra/llrender/llfontgl.cpp index 2b17006289..cd2a62d17c 100644 --- a/indra/llrender/llfontgl.cpp +++ b/indra/llrender/llfontgl.cpp @@ -2436,6 +2436,32 @@ std::string LLFontGL::getFontPathLocal() return local_path; } +// static +std::string LLFontGL::getFontPathUser() +{ + if (gDirUtilp == nullptr) + return {}; + + const std::string user_app_dir = gDirUtilp->getOSUserAppDir(); + if (user_app_dir.empty()) + return {}; + + return gDirUtilp->add(user_app_dir, "fonts") + gDirUtilp->getDirDelimiter(); +} + +// static +std::string LLFontGL::getImportedFontsOverlayPath() +{ + if (gDirUtilp == nullptr) + return {}; + + const std::string user_app_dir = gDirUtilp->getOSUserAppDir(); + if (user_app_dir.empty()) + return {}; + + return gDirUtilp->add(gDirUtilp->add(user_app_dir, "fonts"), "imported_fonts.xml"); +} + void LLFontGL::renderTriangle(LLVector4a* vertex_out, LLVector2* uv_out, LLColor4U* colors_out, const LLRectf& screen_rect, const LLRectf& uv_rect, const LLColor4U& color, F32 slant_amt) const { // slant_amt shifts the bottom edge horizontally relative to the top diff --git a/indra/llrender/llfontgl.h b/indra/llrender/llfontgl.h index f46e74ab99..4b86e1cd30 100644 --- a/indra/llrender/llfontgl.h +++ b/indra/llrender/llfontgl.h @@ -384,6 +384,8 @@ class LLFontGL static std::string getFontPathLocal(); static std::string getFontPathSystem(); + static std::string getFontPathUser(); + static std::string getImportedFontsOverlayPath(); // The shadow of the UI transform, kept on the CPU for the three things // that need to know where a local coordinate lands without asking the diff --git a/indra/llrender/llfontregistry.cpp b/indra/llrender/llfontregistry.cpp index 124b852d17..653c929879 100644 --- a/indra/llrender/llfontregistry.cpp +++ b/indra/llrender/llfontregistry.cpp @@ -348,6 +348,22 @@ bool LLFontRegistry::parseFontInfo(const std::string& xml_filename, const LLSD& success = parse_single_fonts_file(this, path) || success; } + { + const std::string overlay = LLFontGL::getImportedFontsOverlayPath(); + if (!overlay.empty() && gDirUtilp->fileExists(overlay)) + { + if (!parse_single_fonts_file(this, overlay)) + { + LL_WARNS() << "fonts.xml: failed to parse imported-fonts overlay " + << overlay << LL_ENDL; + } + else + { + success = true; + } + } + } + // Resolve cross-family and per-family inherit="true" once across // the merged registry, after all skinned fonts.xml files have been read. // Doing this per-file would double-append references for entries that @@ -1697,6 +1713,12 @@ LLFontGL *LLFontRegistry::createFont(const LLFontDescriptor& desc) bool is_first_found = true; string_vec_t font_search_paths; + // Imported fonts first so they can shadow bundled ones. + const std::string user_font_path = LLFontGL::getFontPathUser(); + if (!user_font_path.empty()) + { + font_search_paths.push_back(user_font_path); + } font_search_paths.push_back(LLFontGL::getFontPathLocal()); font_search_paths.push_back(LLFontGL::getFontPathSystem()); #if LL_DARWIN diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 783d68d97a..f80d43668d 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -158,6 +158,7 @@ if(AL_BUILD_VIEWER) alfloaterexploresounds.cpp alfloaterfeedback.cpp alfloaterfeedbackpreview.cpp + alfloaterfontreset.cpp alfloatergenerictext.cpp alfloatergroupprofile.cpp alfloaterlightbox.cpp @@ -169,6 +170,7 @@ if(AL_BUILD_VIEWER) alfloatersceneexplorerfilters.cpp alfloaterscopes.cpp alfloatertransactionlog.cpp + alimportedfonts.cpp alrenderisolation.cpp alrendersizeoverride.cpp alsceneexplorerpredicate.cpp @@ -1032,6 +1034,7 @@ if(AL_BUILD_VIEWER) alfloaterexploresounds.h alfloaterfeedback.h alfloaterfeedbackpreview.h + alfloaterfontreset.h alfloatergenerictext.h alfloatergroupprofile.h alfloaterlightbox.h @@ -1043,6 +1046,7 @@ if(AL_BUILD_VIEWER) alfloatersceneexplorerfilters.h alfloaterscopes.h alfloatertransactionlog.h + alimportedfonts.h alrenderisolation.h alrendersizeoverride.h alsceneexplorerpredicate.h diff --git a/indra/newview/alfloaterfontreset.cpp b/indra/newview/alfloaterfontreset.cpp new file mode 100644 index 0000000000..9e0a1bc276 --- /dev/null +++ b/indra/newview/alfloaterfontreset.cpp @@ -0,0 +1,71 @@ +/** + * @file alfloaterfontreset.cpp + * @brief Confirmation dialog for the "reset fonts to defaults" recovery action. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "alfloaterfontreset.h" + +#include "alimportedfonts.h" +#include "llbutton.h" +#include "llfloaterpreference.h" +#include "llfloaterreg.h" + +ALFloaterFontReset::ALFloaterFontReset(const LLSD& key) +: LLFloater(key) +{ +} + +bool ALFloaterFontReset::postBuild() +{ + childSetAction("reset_btn", boost::bind(&ALFloaterFontReset::onReset, this)); + childSetAction("cancel_btn", boost::bind(&ALFloaterFontReset::onCancel, this)); + return true; +} + +void ALFloaterFontReset::onOpen(const LLSD& key) +{ + // Focus Cancel so Enter can't clear fonts by accident. + if (LLUICtrl* cancel = getChild("cancel_btn")) + { + cancel->setFocus(true); + } +} + +void ALFloaterFontReset::onReset() +{ + ALImportedFonts::resetOverrides(); + LLFloaterPreference::scheduleFontDropdownRefresh(); + closeFloater(); +} + +void ALFloaterFontReset::onCancel() +{ + closeFloater(); +} + +// static +void ALFloaterFontReset::showConfirm() +{ + LLFloaterReg::showInstance("font_reset"); +} diff --git a/indra/newview/alfloaterfontreset.h b/indra/newview/alfloaterfontreset.h new file mode 100644 index 0000000000..5aff517876 --- /dev/null +++ b/indra/newview/alfloaterfontreset.h @@ -0,0 +1,46 @@ +/** + * @file alfloaterfontreset.h + * @brief Confirmation dialog for resetting UI fonts to defaults. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#ifndef AL_ALFLOATERFONTRESET_H +#define AL_ALFLOATERFONTRESET_H + +#include "llfloater.h" + +class ALFloaterFontReset final : public LLFloater +{ +public: + ALFloaterFontReset(const LLSD& key); + ~ALFloaterFontReset() override = default; + + static void showConfirm(); + + bool postBuild() override; + void onOpen(const LLSD& key) override; + +private: + void onReset(); + void onCancel(); +}; + +#endif // AL_ALFLOATERFONTRESET_H diff --git a/indra/newview/alimportedfonts.cpp b/indra/newview/alimportedfonts.cpp new file mode 100644 index 0000000000..8521002b4f --- /dev/null +++ b/indra/newview/alimportedfonts.cpp @@ -0,0 +1,483 @@ +/** + * @file alimportedfonts.cpp + * @brief User-imported font files: validation, copy into the per-user font + * directory, and the generated imported_fonts.xml overlay. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alimportedfonts.h" + +#include "llfontgl.h" +#include "llfontfreetype.h" +#include "llfontregistry.h" +#include "lldir.h" +#include "llfile.h" +#include "llstring.h" +#include "llviewercontrol.h" + +#include +#include +#include + +namespace +{ + constexpr const char* kSettingImported = "AlchemyImportedFonts"; + constexpr const char* kSettingUIFontOverrides = "AlchemyUIFontOverrides"; + constexpr const char* kCategoryMonospace = "monospace"; + constexpr const char* kCategoryEmoji = "emoji"; + constexpr const char* kCategoryProportional = "proportional"; + + constexpr std::uintmax_t kMaxImportedFontBytes = 64u * 1024u * 1024u; // 64 MiB + + constexpr const char* kSupportedExtensions[] = { "ttf", "otf", "woff2" }; + + constexpr const char* kStyleNormal = "NORMAL"; + constexpr const char* kStyleBold = "BOLD"; + constexpr const char* kStyleItalic = "ITALIC"; + constexpr const char* kStyleBoldItalic = "BOLD|ITALIC"; + + bool hasSupportedExtension(const std::string& path) + { + if (gDirUtilp == nullptr) + return false; + const std::string ext = gDirUtilp->getExtension(path); + for (const char* supported : kSupportedExtensions) + { + if (ext == supported) + return true; + } + return false; + } + + U32 fnv1a(const std::string& text) + { + U32 hash = 2166136261u; + for (char c : text) + { + hash ^= static_cast(static_cast(c)); + hash *= 16777619u; + } + return hash; + } + + std::string makeFamilyKey(const std::string& family) + { + std::string slug; + slug.reserve(family.size()); + for (char c : family) + { + const unsigned char u = static_cast(c); + if (std::isalnum(u)) + slug.push_back(static_cast(std::tolower(u))); + else if (c == ' ' || c == '-' || c == '_') + slug.push_back('_'); + } + if (slug.empty()) + slug = "font"; + + return "AlchemyImported_" + slug + "_" + llformat("%08x", fnv1a(family)); + } + + std::string makeFileSuffix(const std::string& source_path) + { + return llformat("%08x", fnv1a(source_path)); + } + + const char* styleFromInfo(const LLFontFreetype::FaceInfo& info) + { + if (info.bold && info.italic) + return kStyleBoldItalic; + if (info.bold) + return kStyleBold; + if (info.italic) + return kStyleItalic; + return kStyleNormal; + } + + std::string categoryToString(ALImportedFonts::Category category) + { + switch (category) + { + case ALImportedFonts::Category::MONOSPACE: return kCategoryMonospace; + case ALImportedFonts::Category::EMOJI: return kCategoryEmoji; + case ALImportedFonts::Category::PROPORTIONAL: + default: return kCategoryProportional; + } + } + + void upsertFace(LLSD& entry, const std::string& file, const char* style) + { + LLSD files = entry["files"]; + if (!files.isArray()) + files = LLSD::emptyArray(); + + for (LLSD::array_iterator fit = files.beginArray(); fit != files.endArray(); ++fit) + { + if ((*fit)["style"].asString() == style) + { + (*fit)["file"] = file; + entry["files"] = files; + return; + } + } + + LLSD face = LLSD::emptyMap(); + face["file"] = file; + face["style"] = style; + files.append(face); + entry["files"] = files; + } + + // Returns true when a new family entry was created. + bool addFaceToImported(const std::string& family_key, const std::string& label, + const std::string& copied_file, const char* style, + ALImportedFonts::Category category) + { + LLSD list = ALImportedFonts::getImported(); + + for (LLSD::array_iterator it = list.beginArray(); it != list.endArray(); ++it) + { + if ((*it)["name"].asString() == family_key) + { + upsertFace(*it, copied_file, style); + gSavedSettings.setLLSD(kSettingImported, list); + return false; + } + } + + LLSD entry = LLSD::emptyMap(); + entry["name"] = family_key; + entry["label"] = label; + entry["category"] = categoryToString(category); + entry["files"] = LLSD::emptyArray(); + upsertFace(entry, copied_file, style); + list.append(entry); + + gSavedSettings.setLLSD(kSettingImported, list); + return true; + } + + void deleteFamilyFiles(const LLSD& entry) + { + const std::string dir = ALImportedFonts::getUserFontDir(false); + if (dir.empty()) + return; + + const LLSD files = entry["files"]; + for (LLSD::array_const_iterator fit = files.beginArray(); fit != files.endArray(); ++fit) + { + const std::string file = (*fit)["file"].asString(); + if (!file.empty()) + LLFile::remove(gDirUtilp->add(dir, file), 0); + } + } + + // Drop overrides that point at a removed family. + void pruneOverridesForFamily(const std::string& family_name) + { + LLSD overrides = gSavedSettings.getLLSD(kSettingUIFontOverrides); + if (!overrides.isMap()) + return; + + std::vector to_erase; + for (LLSD::map_const_iterator it = overrides.beginMap(); it != overrides.endMap(); ++it) + { + const LLSD& value = it->second; + const std::string stored = (value.isMap() && value.has("value")) + ? value["value"].asString() : value.asString(); + if (stored == family_name) + to_erase.push_back(it->first); + } + if (to_erase.empty()) + return; + + for (const std::string& key : to_erase) + overrides.erase(key); + gSavedSettings.setLLSD(kSettingUIFontOverrides, overrides); + } + + void notifyRegistryChanged() + { + LLFontGL::markFontsXmlDirty(); + LLFontGL::schedulePendingReload(); + } +} + +// static +bool ALImportedFonts::isSupportedFontFile(const std::string& path) +{ + return hasSupportedExtension(path); +} + +// static +void ALImportedFonts::resetOverrides() +{ + gSavedSettings.setLLSD(kSettingUIFontOverrides, LLSD::emptyMap()); + notifyRegistryChanged(); +} + +// static +std::string ALImportedFonts::getUserFontDir(bool create) +{ + if (gDirUtilp == nullptr) + return {}; + + const std::string dir = gDirUtilp->add(gDirUtilp->getOSUserAppDir(), "fonts"); + if (create && !LLFile::isdir(dir)) + { + LLFile::mkdir(dir); + } + return dir; +} + +// static +std::string ALImportedFonts::getOverlayPath() +{ + return LLFontGL::getImportedFontsOverlayPath(); +} + +// static +LLSD ALImportedFonts::getImported() +{ + const LLSD list = gSavedSettings.getLLSD(kSettingImported); + return list.isArray() ? list : LLSD::emptyArray(); +} + +// static +std::string ALImportedFonts::categorySuffix(const LLSD& family_entry) +{ + const std::string category = family_entry["category"].asString(); + if (category == kCategoryMonospace) + return " (mono)"; + if (category == kCategoryEmoji) + return " (emoji)"; + return {}; +} + +// static +std::string ALImportedFonts::regenerateOverlay() +{ + // Create the user font directory if needed. + if (getUserFontDir(true).empty()) + return {}; + + const std::string path = getOverlayPath(); + if (path.empty()) + return {}; + + auto xml_escape = [](const std::string& s) + { + std::string r; + r.reserve(s.size()); + for (char c : s) + { + switch (c) + { + case '&': r += "&"; break; + case '<': r += "<"; break; + case '>': r += ">"; break; + case '"': r += """; break; + case '\'': r += "'"; break; + default: r += c; break; + } + } + return r; + }; + + std::string out; + out += "\n"; + out += "\n"; + out += "\n"; + + const LLSD imported = getImported(); + for (LLSD::array_const_iterator eit = imported.beginArray(); eit != imported.endArray(); ++eit) + { + const LLSD& entry = *eit; + const std::string name = entry["name"].asString(); + if (name.empty()) + continue; + + const std::string label = entry["label"].asString(); + const std::string category = entry["category"].asString(); + + std::string by_style[4]; // index by the slot order below + const LLSD files = entry["files"]; + for (LLSD::array_const_iterator fit = files.beginArray(); fit != files.endArray(); ++fit) + { + const std::string style = (*fit)["style"].asString(); + const std::string file = (*fit)["file"].asString(); + if (style == kStyleNormal) by_style[0] = file; + else if (style == kStyleBold) by_style[1] = file; + else if (style == kStyleItalic) by_style[2] = file; + else if (style == kStyleBoldItalic) by_style[3] = file; + } + + out += " & sources, ImportResult& result) +{ + result = ImportResult(); + + const std::string dir = getUserFontDir(true); + if (dir.empty()) + { + LL_WARNS() << "Imported fonts: no user font directory available" << LL_ENDL; + for (const std::string& source : sources) + result.failed.push_back({ source, ImportResult::RejectReason::COPY_FAILED }); + return false; + } + + for (const std::string& source : sources) + { + using RejectReason = ImportResult::RejectReason; + + if (!LLFile::isfile(source) || !hasSupportedExtension(source)) + { + result.failed.push_back({ source, RejectReason::UNSUPPORTED_TYPE }); + continue; + } + + const std::string leaf = gDirUtilp->getBaseFileName(source); + if (!LLFile::isSafeFileName(leaf)) + { + LL_WARNS() << "Imported fonts: unsafe file name '" << leaf << "'" << LL_ENDL; + result.failed.push_back({ source, RejectReason::UNSAFE_NAME }); + continue; + } + + std::error_code size_ec; + const std::uintmax_t size = std::filesystem::file_size(source, size_ec); + if (size_ec || size > kMaxImportedFontBytes) + { + LL_WARNS() << "Imported fonts: '" << source << "' is too large or unreadable" << LL_ENDL; + result.failed.push_back({ source, RejectReason::TOO_LARGE }); + continue; + } + + const LLFontFreetype::FaceInfo info = LLFontFreetype::probeFaceInfo(source); + if (!info.valid) + { + LL_WARNS() << "Imported fonts: '" << source << "' is not a readable font" << LL_ENDL; + result.failed.push_back({ source, RejectReason::UNREADABLE }); + continue; + } + + // Copy with a collision-proof name; getExtension() omits the dot. + const std::string ext = gDirUtilp->getExtension(leaf); + const std::string stem = leaf.substr(0, leaf.size() - (ext.empty() ? 0 : ext.size() + 1)); + const std::string dest_leaf = stem + "." + makeFileSuffix(source) + "." + ext; + const std::string dest = gDirUtilp->add(dir, dest_leaf); + + std::error_code ec; + if (!LLFile::copy(source, dest, std::filesystem::copy_options::overwrite_existing, ec)) + { + LL_WARNS() << "Imported fonts: could not copy " << source << " -> " << dest + << " (" << ec.message() << ")" << LL_ENDL; + result.failed.push_back({ source, RejectReason::COPY_FAILED }); + continue; + } + + const Category category = info.has_color ? Category::EMOJI + : info.fixed_width ? Category::MONOSPACE + : Category::PROPORTIONAL; + + if (addFaceToImported(makeFamilyKey(info.family), info.family, dest_leaf, + styleFromInfo(info), category)) + { + result.families_added++; + } + result.files_copied++; + } + + regenerateOverlay(); + notifyRegistryChanged(); + return result.anyCopied(); +} + +// static +bool ALImportedFonts::removeFamily(const std::string& family_name) +{ + const LLSD list = getImported(); + if (!list.isArray()) + return false; + + LLSD kept = LLSD::emptyArray(); + bool removed = false; + for (LLSD::array_const_iterator it = list.beginArray(); it != list.endArray(); ++it) + { + if ((*it)["name"].asString() == family_name) + { + deleteFamilyFiles(*it); + removed = true; + continue; + } + kept.append(*it); + } + + if (!removed) + return false; + + gSavedSettings.setLLSD(kSettingImported, kept); + pruneOverridesForFamily(family_name); + regenerateOverlay(); + notifyRegistryChanged(); + return true; +} diff --git a/indra/newview/alimportedfonts.h b/indra/newview/alimportedfonts.h new file mode 100644 index 0000000000..d7ab2d6a61 --- /dev/null +++ b/indra/newview/alimportedfonts.h @@ -0,0 +1,99 @@ +/** + * @file alimportedfonts.h + * @brief User-imported font files: validation, copy into the per-user font + * directory, and the generated imported_fonts.xml overlay the font + * registry parses. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#ifndef AL_ALIMPORTEDFONTS_H +#define AL_ALIMPORTEDFONTS_H + +#include "llsd.h" + +#include +#include + +// Manages user-imported font files and the overlay the font registry parses. +class ALImportedFonts +{ +public: + // Detected from FreeType flags at import time. + enum class Category + { + PROPORTIONAL, + MONOSPACE, + EMOJI, + }; + + struct ImportResult + { + enum class RejectReason + { + UNSUPPORTED_TYPE, + UNSAFE_NAME, + TOO_LARGE, + UNREADABLE, + COPY_FAILED, + }; + + struct Failure + { + std::string source; + RejectReason reason; + }; + + S32 families_added = 0; + S32 files_copied = 0; + std::vector failed; + + bool anyCopied() const { return files_copied > 0; } + }; + + // Import files, copying valid ones into the per-user font directory. + static bool importFiles(const std::vector& sources, ImportResult& result); + + // Remove an imported family and its copied files. + static bool removeFamily(const std::string& family_name); + + static LLSD getImported(); + + // Display suffix for the family's category, e.g. " (mono)". + static std::string categorySuffix(const LLSD& family_entry); + + // Rewrite imported_fonts.xml from the current setting. + static std::string regenerateOverlay(); + + static std::string getOverlayPath(); + + // Per-user font directory. + static std::string getUserFontDir(bool create = false); + + static bool isSupportedFontFile(const std::string& path); + + // Clear font overrides, restoring the built-in defaults. + static void resetOverrides(); + +private: + ALImportedFonts() = delete; +}; + +#endif // AL_ALIMPORTEDFONTS_H diff --git a/indra/newview/app_settings/cmd_line.xml b/indra/newview/app_settings/cmd_line.xml index 54046708bd..66c3e28dff 100644 --- a/indra/newview/app_settings/cmd_line.xml +++ b/indra/newview/app_settings/cmd_line.xml @@ -297,6 +297,14 @@ ReplaySession + resetfonts + + desc + Clear all custom UI/monospace/emoji font overrides before fonts load, restoring the built-in fonts. Recovery for an unreadable UI. Imported fonts are not deleted. + map-to + AlchemyResetFontsOnStartup + + rotate map-to diff --git a/indra/newview/app_settings/settings_alchemy.xml b/indra/newview/app_settings/settings_alchemy.xml index 5075ee26bd..31190301aa 100644 --- a/indra/newview/app_settings/settings_alchemy.xml +++ b/indra/newview/app_settings/settings_alchemy.xml @@ -2946,6 +2946,38 @@ Value + AlchemyImportedFonts + + Comment + + User-imported font families. Array of maps, one per imported family: + { name (canonical internal key), label (display name), category + (proportional|monospace|emoji), files: [ { file (leaf in the + per-user fonts dir), style (NORMAL|BOLD|ITALIC|"BOLD|ITALIC") } ] }. + Source of truth for the generated fonts/imported_fonts.xml overlay. + + Persist + 1 + Type + LLSD + Value + + + AlchemyResetFontsOnStartup + + Comment + + Set by the --resetfonts command-line option. When true at startup the + viewer clears all AlchemyUIFontOverrides before fonts load, restoring the + built-in fonts. Not persisted; it is a one-shot recovery action. + + Persist + 0 + Type + Boolean + Value + 0 + AlchemyVOCacheEvictionMode Comment diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index 1036854096..7bf00c33c1 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -29,6 +29,7 @@ #include "llappviewer.h" #include "alcrashreporter.h" +#include "alimportedfonts.h" // Viewer includes #include "llversioninfo.h" @@ -3104,6 +3105,19 @@ bool LLAppViewer::initConfiguration() } } + // Clear font overrides before fonts load when --resetfonts was passed. + if (gSavedSettings.getBOOL("AlchemyResetFontsOnStartup")) + { + LL_INFOS() << "--resetfonts: clearing AlchemyUIFontOverrides" << LL_ENDL; + gSavedSettings.setLLSD("AlchemyUIFontOverrides", LLSD::emptyMap()); + } + + // Regenerate the imported-fonts overlay on startup. + if (ALImportedFonts::getImported().size() > 0) + { + ALImportedFonts::regenerateOverlay(); + } + std::string CmdLineChannel(gSavedSettings.getString("CmdLineChannel")); if (!CmdLineChannel.empty()) { diff --git a/indra/newview/llfilepicker.cpp b/indra/newview/llfilepicker.cpp index f304c8c8d2..33726ce0ca 100644 --- a/indra/newview/llfilepicker.cpp +++ b/indra/newview/llfilepicker.cpp @@ -67,6 +67,7 @@ LLFilePicker LLFilePicker::sInstance; #define SCRIPT_FILTER L"Script files (*.lsl; *.lua; *.luau)\0*.lsl;*.lua;*.luau\0" #define DICTIONARY_FILTER L"Dictionary files (*.dic; *.xcu)\0*.dic;*.xcu\0" #define ZIP_FILTER L"ZIP files (*.zip)\0*.zip\0" +#define FONT_FILTER L"Font files (*.ttf; *.otf; *.woff2)\0*.ttf;*.otf;*.woff2\0" #endif #if LL_DARWIN && !LL_SDL_WINDOW @@ -236,6 +237,9 @@ namespace case LLFilePicker::FFLOAD_ZIP: filter_vec.push_back({ "ZIP files (*.zip)", "zip" }); break; + case LLFilePicker::FFLOAD_FONT: + filter_vec.push_back({ "Font files (*.ttf; *.otf; *.woff2)", "ttf;otf;woff2" }); + break; default: break; } @@ -647,6 +651,10 @@ bool LLFilePicker::setupFilter(ELoadFilter filter) mOFN.lpstrFilter = ZIP_FILTER \ L"\0"; break; + case FFLOAD_FONT: + mOFN.lpstrFilter = FONT_FILTER \ + L"\0"; + break; default: res = false; break; @@ -1137,6 +1145,11 @@ std::unique_ptr> LLFilePicker::navOpenFilterProc(ELoadF case FFLOAD_ZIP: allowedv->push_back("zip"); break; + case FFLOAD_FONT: + allowedv->push_back("ttf"); + allowedv->push_back("otf"); + allowedv->push_back("woff2"); + break; case FFLOAD_DIRECTORY: break; default: diff --git a/indra/newview/llfilepicker.h b/indra/newview/llfilepicker.h index f2e2bdce3c..be8119cfd0 100644 --- a/indra/newview/llfilepicker.h +++ b/indra/newview/llfilepicker.h @@ -80,7 +80,8 @@ class LLFilePicker FFLOAD_MATERIAL = 15, FFLOAD_MATERIAL_TEXTURE = 16, FFLOAD_HDRI = 17, - FFLOAD_ZIP = 18 + FFLOAD_ZIP = 18, + FFLOAD_FONT = 19 }; enum ESaveFilter diff --git a/indra/newview/llfloaterpreference.cpp b/indra/newview/llfloaterpreference.cpp index 7582fe8282..8fda3f8054 100644 --- a/indra/newview/llfloaterpreference.cpp +++ b/indra/newview/llfloaterpreference.cpp @@ -36,6 +36,8 @@ #include "alavatargroups.h" #include "alchattimestamp.h" +#include "alfloaterfontreset.h" +#include "alimportedfonts.h" #include "llaudioengine.h" #include "lllogchat.h" #include "llstyle.h" @@ -409,6 +411,9 @@ LLFloaterPreference::LLFloaterPreference(const LLSD& key) mCommitCallbackRegistrar.add("Pref.SelectSkin", boost::bind(&LLFloaterPreference::onSelectSkin, this, _2)); mCommitCallbackRegistrar.add("Pref.UIFontSelected", boost::bind(&LLFloaterPreference::onUIFontSelected, this, _1, _2)); mCommitCallbackRegistrar.add("Pref.UIFontFileEntered", boost::bind(&LLFloaterPreference::onUIFontFileEntered, this, _1, _2)); + mCommitCallbackRegistrar.add("Pref.ImportFont", boost::bind(&LLFloaterPreference::onImportFont, this)); + mCommitCallbackRegistrar.add("Pref.RemoveImportedFont", boost::bind(&LLFloaterPreference::onRemoveImportedFont, this)); + mCommitCallbackRegistrar.add("Pref.ResetFonts", boost::bind(&LLFloaterPreference::onResetFonts, this)); mCommitCallbackRegistrar.add("Pref.ResetControlDefault", [](LLUICtrl* ctrl, const LLSD& userdata) { @@ -550,6 +555,7 @@ bool LLFloaterPreference::postBuild() loadUserSkins(); populateUIFontDropdowns(); + refreshImportedFontList(); LLSliderCtrl* fov_slider = getChild("camera_fov"); fov_slider->setMinValue(LLViewerCamera::getInstance()->getMinView()); @@ -1157,6 +1163,137 @@ void LLFloaterPreference::onUIFontFileEntered(LLUICtrl* ctrl, const LLSD& userda } } +void LLFloaterPreference::refreshImportedFontList() +{ + LLScrollListCtrl* list = findChild("imported_font_list"); + if (!list) return; + + list->clearRows(); + const LLSD imported = ALImportedFonts::getImported(); + for (LLSD::array_const_iterator it = imported.beginArray(); it != imported.endArray(); ++it) + { + const LLSD& entry = *it; + const std::string name = entry["name"].asString(); + const std::string label = entry["label"].asString(); + + std::string display = (label.empty() ? name : label) + ALImportedFonts::categorySuffix(entry); + + LLSD row; + row["id"] = name; + row["alt_value"] = display; // friendly label for notifications + row["columns"][0]["value"] = display; + list->addElement(row); + } + + LLButton* remove_btn = findChild("remove_imported_font"); + if (remove_btn) + remove_btn->setEnabled(list->getItemCount() > 0); +} + +void LLFloaterPreference::onImportFont() +{ + LLFilePickerReplyThread::startPicker(boost::bind(&LLFloaterPreference::onImportFontCallback, this, _1), + LLFilePicker::FFLOAD_FONT, true); +} + +void LLFloaterPreference::onImportFontCallback(const std::vector& filenames) +{ + if (filenames.empty()) + return; + + ALImportedFonts::ImportResult result; + ALImportedFonts::importFiles(filenames, result); + + if (result.anyCopied()) + { + LLSD args; + args["COUNT"] = result.files_copied; + LLNotificationsUtil::add("ImportFontSuccess", args); + } + + if (!result.failed.empty()) + { + std::string joined; + bool any_unsupported = false; + for (size_t i = 0; i < result.failed.size(); ++i) + { + if (i) + joined += ", "; + joined += gDirUtilp->getBaseFileName(result.failed[i].source); + if (result.failed[i].reason == ALImportedFonts::ImportResult::RejectReason::UNSUPPORTED_TYPE) + any_unsupported = true; + } + + LLSD args; + args["FILES"] = joined; + LLNotificationsUtil::add(any_unsupported ? "ImportFontFailureType" : "ImportFontFailure", args); + } + + if (result.anyCopied()) + { + refreshImportedFontList(); + scheduleFontDropdownRefresh(); + } +} + +void LLFloaterPreference::onRemoveImportedFont() +{ + LLScrollListCtrl* list = findChild("imported_font_list"); + if (!list) return; + + const LLScrollListItem* item = list->getFirstSelected(); + if (!item) return; + + const std::string family = item->getValue().asString(); + if (family.empty()) + return; + + const std::string label = item->getAltValue().asString(); + + LLSD args; + args["FONT"] = label.empty() ? family : label; + LLSD payload; + payload["FONT"] = family; + payload["LABEL"] = args["FONT"]; + LLNotificationsUtil::add("ConfirmRemoveImportedFont", args, payload, + boost::bind(&LLFloaterPreference::callbackRemoveImportedFont, this, _1, _2)); +} + +void LLFloaterPreference::callbackRemoveImportedFont(const LLSD& notification, const LLSD& response) +{ + const S32 option = LLNotificationsUtil::getSelectedOption(notification, response); + if (option != 0) // not OK + return; + + const std::string family = notification["payload"]["FONT"].asString(); + if (ALImportedFonts::removeFamily(family)) + { + LLSD args; + args["FONT"] = notification["payload"]["LABEL"].asString(); + LLNotificationsUtil::add("RemoveImportedFontSuccess", args); + refreshImportedFontList(); + scheduleFontDropdownRefresh(); + } +} + +void LLFloaterPreference::scheduleFontDropdownRefresh() +{ + // Re-resolve the open Preferences instance after the font reload. + LLEventTimer::run_after(0.25f, []() + { + LLFloaterPreference* prefs = LLFloaterReg::findTypedInstance("preferences"); + if (prefs && prefs->getChild("imported_font_list")) + { + prefs->populateUIFontDropdowns(); + } + }); +} + +void LLFloaterPreference::onResetFonts() +{ + ALFloaterFontReset::showConfirm(); +} + LLFloaterPreference::~LLFloaterPreference() { LLConversationLog::instance().removeObserver(this); diff --git a/indra/newview/llfloaterpreference.h b/indra/newview/llfloaterpreference.h index 349d91b276..e226c12ea1 100644 --- a/indra/newview/llfloaterpreference.h +++ b/indra/newview/llfloaterpreference.h @@ -218,6 +218,10 @@ class LLFloaterPreference final : public LLFloater, public LLAvatarPropertiesObs void setRecommendedSettings(); void resetAutotuneSettings(); +public: + // Refresh the font dropdowns in the open Preferences floater. + static void scheduleFontDropdownRefresh(); + private: void onDeleteTranscripts(); @@ -254,6 +258,13 @@ class LLFloaterPreference final : public LLFloater, public LLAvatarPropertiesObs // the raw filename. void onUIFontFileEntered(LLUICtrl* ctrl, const LLSD& userdata); + void refreshImportedFontList(); + void onImportFont(); + void onImportFontCallback(const std::vector& filenames); + void onRemoveImportedFont(); + void callbackRemoveImportedFont(const LLSD& notification, const LLSD& response); + void onResetFonts(); + static std::string sSkin; notifications_map mNotificationOptions; bool mGotPersonalInfo; diff --git a/indra/newview/llviewerfloaterreg.cpp b/indra/newview/llviewerfloaterreg.cpp index e2bb2cd5ad..8b42a9c588 100644 --- a/indra/newview/llviewerfloaterreg.cpp +++ b/indra/newview/llviewerfloaterreg.cpp @@ -42,6 +42,7 @@ #include "alfloaterexploresounds.h" #include "alfloaterfeedback.h" #include "alfloaterfeedbackpreview.h" +#include "alfloaterfontreset.h" #include "alfloatergenerictext.h" #include "alfloatergroupprofile.h" #include "alfloaterlightbox.h" @@ -589,6 +590,7 @@ void LLViewerFloaterReg::registerFloaters() LLFloaterReg::add("delete_queue", "floater_script_queue.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("feedback", "floater_al_feedback.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("feedback_preview", "floater_al_feedback_preview.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); + LLFloaterReg::add("font_reset", "floater_al_font_reset.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("generic_text", "floater_al_generic_text.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("group_profile", "floater_al_group_profile.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); LLFloaterReg::add("legacy_profile", "floater_al_profile_legacy.xml", (LLFloaterBuildFunc)&LLFloaterReg::build); diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index f8eb85e510..bad4a6b0dd 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -42,6 +42,7 @@ #include "llcommunicationchannel.h" #include "llfloaterreg.h" #include "llhudicon.h" +#include "alfloaterfontreset.h" #include "llmeshrepository.h" #include "llnotificationhandler.h" #include "llpanellogin.h" @@ -3217,6 +3218,13 @@ bool LLViewerWindow::handleKey(KEY key, MASK mask) // hide tooltips on keypress LLToolTipMgr::instance().blockToolTips(); + // Ctrl/Cmd+Alt+Shift+F opens the font-reset dialog. + if ((key == 'f' || key == 'F') && (mask & MASK_CONTROL) && (mask & MASK_ALT) && (mask & MASK_SHIFT)) + { + ALFloaterFontReset::showConfirm(); + return true; + } + // Menus get handled on key down instead of key up // so keybindings have to be recorded before that if (LLSetKeyBindDialog::recordKey(key, mask, true)) diff --git a/indra/newview/skins/default/xui/en/floater_al_font_reset.xml b/indra/newview/skins/default/xui/en/floater_al_font_reset.xml new file mode 100644 index 0000000000..0614325bd8 --- /dev/null +++ b/indra/newview/skins/default/xui/en/floater_al_font_reset.xml @@ -0,0 +1,48 @@ + + + Reset the viewer's custom UI, monospace, and emoji font settings to the built-in defaults. Imported font files will not be deleted. + + + +