diff --git a/indra/CMakeLists.txt b/indra/CMakeLists.txt index 8fc37d895a..dee010262e 100644 --- a/indra/CMakeLists.txt +++ b/indra/CMakeLists.txt @@ -76,6 +76,10 @@ else () option(USE_SDL_WINDOW "Build with SDL based window management and input (experimental on Windows)" OFF) endif () +# Spell checking backend +cmake_dependent_option(USE_NSSPELLCHECKER "Use the native macOS NSSpellChecker instead of Hunspell" ON "APPLE" OFF) +cmake_dependent_option(USE_WINSPELLCHECK "Use the native Windows Spell Checking API instead of Hunspell" ON "WIN32" OFF) + # Configure crash reporting option(RELEASE_CRASH_REPORTING "Enable use of crash reporting in release builds" OFF) option(NON_RELEASE_CRASH_REPORTING "Enable use of crash reporting in developer builds" OFF) @@ -181,6 +185,10 @@ if(BUILD_DULLAHAN_EXAMPLE) list(APPEND VCPKG_MANIFEST_FEATURES "dullahan-example") endif() +if (NOT USE_NSSPELLCHECKER AND NOT USE_WINSPELLCHECK) + list(APPEND VCPKG_MANIFEST_FEATURES "hunspell") +endif() + # Export compile_commands.json for ninja and makefile generators set(CMAKE_EXPORT_COMPILE_COMMANDS ON) @@ -275,6 +283,7 @@ include(Mesa) include(Meshoptimizer) include(Mikktspace) include(NDOF) +include(NSSpellChecker) include(NVAPI) include(OpenAL) include(OpenGL) @@ -297,6 +306,7 @@ include(Vorbis) include(WebP) include(WebRTC) include(websocketpp) +include(WinSpellCheck) include(xxHash) include(ZLIBNG) diff --git a/indra/cmake/Copy3rdPartyLibs.cmake b/indra/cmake/Copy3rdPartyLibs.cmake index c889caf1ca..65f0931f3d 100644 --- a/indra/cmake/Copy3rdPartyLibs.cmake +++ b/indra/cmake/Copy3rdPartyLibs.cmake @@ -88,9 +88,13 @@ if(WINDOWS) endif() elseif(DARWIN) set(vcpkg_lib_dir "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib") - set(release_libs - "libhunspell-1.7.0.dylib" - ) + if (USE_NSSPELLCHECKER) + set(release_libs "") + else() + set(release_libs + "libhunspell-1.7.0.dylib" + ) + endif() elseif(LINUX) set(vcpkg_lib_dir "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib") set(release_libs "") diff --git a/indra/cmake/Hunspell.cmake b/indra/cmake/Hunspell.cmake index 30cd70f13d..d069fc2277 100644 --- a/indra/cmake/Hunspell.cmake +++ b/indra/cmake/Hunspell.cmake @@ -3,7 +3,9 @@ include_guard() add_library(ll::hunspell INTERFACE IMPORTED) -find_package(PkgConfig REQUIRED) +if (NOT USE_NSSPELLCHECKER AND NOT USE_WINSPELLCHECK) + find_package(PkgConfig REQUIRED) -pkg_check_modules(hunspell REQUIRED IMPORTED_TARGET GLOBAL hunspell) -target_link_libraries(ll::hunspell INTERFACE PkgConfig::hunspell) + pkg_check_modules(hunspell REQUIRED IMPORTED_TARGET GLOBAL hunspell) + target_link_libraries(ll::hunspell INTERFACE PkgConfig::hunspell) +endif() diff --git a/indra/cmake/NSSpellChecker.cmake b/indra/cmake/NSSpellChecker.cmake new file mode 100644 index 0000000000..e1440d78ab --- /dev/null +++ b/indra/cmake/NSSpellChecker.cmake @@ -0,0 +1,11 @@ +# -*- cmake -*- + +include_guard() + +if (USE_NSSPELLCHECKER) + # Link target for the macOS NSSpellChecker engine (llspellcheckengine_mac.mm). No compile + # definitions are needed: the engine is selected by which source CMake compiles, and the shared + # llspellcheck.h is platform-clean (it only forward-declares the abstract LLSpellCheckEngine). + add_library(ll::nsspellchecker INTERFACE IMPORTED) + target_link_libraries(ll::nsspellchecker INTERFACE "-framework AppKit" "-framework Foundation") +endif () diff --git a/indra/cmake/WinSpellCheck.cmake b/indra/cmake/WinSpellCheck.cmake new file mode 100644 index 0000000000..e4a25abd6a --- /dev/null +++ b/indra/cmake/WinSpellCheck.cmake @@ -0,0 +1,13 @@ +# -*- cmake -*- + +include_guard() + +if (USE_WINSPELLCHECK) + # Link target for the Windows Spell Checking engine (llspellcheckengine_win32.cpp). ole32 provides + # CoInitializeEx/CoCreateInstance/CoTaskMemFree/CoUninitialize; ships with the + # Windows SDK and the API uses LPCWSTR (not BSTR), so no oleaut32/uuid.lib is required. No compile + # definitions are needed: the engine is selected by which source CMake compiles, and the shared + # llspellcheck.h is platform-clean (it only forward-declares the abstract LLSpellCheckEngine). + add_library(ll::winspellcheck INTERFACE IMPORTED) + target_link_libraries(ll::winspellcheck INTERFACE ole32) +endif () diff --git a/indra/llui/CMakeLists.txt b/indra/llui/CMakeLists.txt index a8a4619a42..b083b8cf54 100644 --- a/indra/llui/CMakeLists.txt +++ b/indra/llui/CMakeLists.txt @@ -70,7 +70,6 @@ target_sources(llui llsearcheditor.cpp llslider.cpp llsliderctrl.cpp - llspellcheck.cpp llspinctrl.cpp llstatbar.cpp llstatgraph.cpp @@ -190,6 +189,7 @@ target_sources(llui llsliderctrl.h llslider.h llspellcheck.h + llspellcheckengine.h llspellcheckmenuhandler.h llspinctrl.h llstatbar.h @@ -234,6 +234,20 @@ target_sources(llui llxyvector.h ) +# Spell checking: the backend-agnostic LLSpellChecker core (llspellcheck.cpp) is always compiled; +# exactly one platform engine is selected by build option — native macOS NSSpellChecker +# (Objective-C++), native Windows Spell Checking API, or Hunspell (default). +target_sources(llui PRIVATE llspellcheck.cpp) +if (USE_NSSPELLCHECKER) + target_sources(llui PRIVATE llspellcheckengine_mac.mm) + set_source_files_properties(llspellcheckengine_mac.mm PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) +elseif (USE_WINSPELLCHECK) + target_sources(llui PRIVATE llspellcheckengine_win32.cpp) + set_source_files_properties(llspellcheckengine_win32.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) +else () + target_sources(llui PRIVATE llspellcheckengine_hunspell.cpp) +endif () + SET(llurlentry_TEST_DEPENDENCIES llurlmatch.cpp llurlregistry.cpp @@ -259,14 +273,28 @@ target_link_libraries(llui llxml llmath llcommon - ll::hunspell ) +if (USE_NSSPELLCHECKER) + target_link_libraries(llui PUBLIC ll::nsspellchecker) +elseif (USE_WINSPELLCHECK) + target_link_libraries(llui PUBLIC ll::winspellcheck) +else () + target_link_libraries(llui PUBLIC ll::hunspell) +endif () + target_precompile_headers(llui REUSE_FROM llprecompiled) # Add tests if(BUILD_TESTING) - set(test_libs llmessage llcorehttp llxml llrender llcommon ll::hunspell) + if (USE_NSSPELLCHECKER) + set(spellcheck_lib ll::nsspellchecker) + elseif (USE_WINSPELLCHECK) + set(spellcheck_lib ll::winspellcheck) + else () + set(spellcheck_lib ll::hunspell) + endif () + set(test_libs llmessage llcorehttp llxml llrender llcommon ${spellcheck_lib}) SET(llui_TEST_SOURCE_FILES llurlmatch.cpp @@ -275,7 +303,7 @@ if(BUILD_TESTING) LL_ADD_PROJECT_UNIT_TESTS(llui "${llui_TEST_SOURCE_FILES}") # INTEGRATION TESTS - set(test_libs llui llmessage llcorehttp llxml llrender llcommon ll::hunspell) + set(test_libs llui llmessage llcorehttp llxml llrender llcommon ${spellcheck_lib}) set(test_project llui) LL_ADD_INTEGRATION_TEST(llurlentry llurlentry.cpp "${test_libs}" "${test_project}") LL_ADD_INTEGRATION_TEST(llemojidictionary llemojidictionary.cpp "${test_libs}" "${test_project}") diff --git a/indra/llui/llspellcheck.cpp b/indra/llui/llspellcheck.cpp index e15616a16b..8be988b223 100644 --- a/indra/llui/llspellcheck.cpp +++ b/indra/llui/llspellcheck.cpp @@ -30,7 +30,10 @@ #include "llsdserialize.h" #include "llspellcheck.h" -#include +#include "llspellcheckengine.h" + +#include +#include static const std::string DICT_DIR = "dictionaries"; static const std::string DICT_FILE_CUSTOM = "user_custom.dic"; @@ -41,42 +44,137 @@ static const std::string DICT_FILE_USER = "user_dictionaries.xml"; LLSpellChecker::settings_change_signal_t LLSpellChecker::sSettingsChangeSignal; +namespace +{ + // Read a viewer ".dic" word list (first line is a word count) into 'out', appending each word. + void readDictWords(const std::string& dict_path, std::vector& out) + { + if (!gDirUtilp->fileExists(dict_path)) + { + return; + } + llifstream file_in(dict_path.c_str(), std::ios::in); + if (!file_in.is_open()) + { + return; + } + std::string word; int line_num = 0; + while (std::getline(file_in, word)) + { + // Skip over the first line since that's just a line count + if ((0 != line_num) && (!word.empty())) + { + out.push_back(word); + } + line_num++; + } + } +} + +// static +std::string LLSpellCheckEngine::matchLanguage(const std::string& name, const std::vector& available_tags, char separator) +{ + // Build the region-qualified candidate: lowercased language, uppercased region, joined by + // 'separator' (e.g. "en_us" -> "en" + sep + "US"); a name without a region stays bare. + std::string lang(name); + std::string candidate; + const size_t us = name.find('_'); + if (us == std::string::npos) + { + LLStringUtil::toLower(lang); + candidate = lang; + } + else + { + lang = name.substr(0, us); + std::string region = name.substr(us + 1); + LLStringUtil::toLower(lang); + LLStringUtil::toUpper(region); + candidate = lang + separator + region; + } + std::string candidate_lower(candidate); + LLStringUtil::toLower(candidate_lower); + + // Pass 1: exact region-qualified match (case-insensitive). + for (const std::string& tag : available_tags) + { + std::string tag_lower(tag); + LLStringUtil::toLower(tag_lower); + if (tag_lower == candidate_lower) + { + return tag; + } + } + // Pass 2: bare language match (the OS often exposes only region-specific tags, so this may miss). + for (const std::string& tag : available_tags) + { + std::string tag_lower(tag); + LLStringUtil::toLower(tag_lower); + if (tag_lower == lang) + { + return tag; + } + } + // Pass 3: any installed variant of the same primary language (e.g. want "en_au", have "en-GB"). + for (const std::string& tag : available_tags) + { + std::string tag_lower(tag); + LLStringUtil::toLower(tag_lower); + const size_t sep = tag_lower.find_first_of("-_"); + const std::string tag_lang = (sep == std::string::npos) ? tag_lower : tag_lower.substr(0, sep); + if (tag_lang == lang) + { + return tag; + } + } + return std::string(); +} + LLSpellChecker::LLSpellChecker() { + mEngine = LLSpellCheckEngine::create(); + // Load initial dictionary information refreshDictionaryMap(); } -LLSpellChecker::~LLSpellChecker() -{ -} +// Defined here (not defaulted in the header) so unique_ptr can destroy a +// complete engine type. (pImpl idiom.) +LLSpellChecker::~LLSpellChecker() = default; bool LLSpellChecker::checkSpelling(const std::string& word) const { - if ( (!mHunspell) || (word.length() < 3) || (0 != mHunspell->spell(word)) ) + if ( (!mEngine) || (!mEngine->isActive()) || (word.length() < 3) ) { return true; } - if (!mIgnoreList.empty()) + // The engine checks only the active language; custom/ignore/glossary words are accepted by us. + if (mEngine->checkWord(word)) { - std::string word_lower(word); - LLStringUtil::toLower(word_lower); - return (mIgnoreList.end() != std::find(mIgnoreList.begin(), mIgnoreList.end(), word_lower)); + return true; } - return false; + return isAccepted(word); } S32 LLSpellChecker::getSuggestions(const std::string& word, std::vector& suggestions) const { suggestions.clear(); - if ( (!mHunspell) || (word.length() < 3) ) + if ( (!mEngine) || (!mEngine->isActive()) || (word.length() < 3) ) { return 0; } + return mEngine->getSuggestions(word, suggestions); +} - suggestions = mHunspell->suggest(word); - - return static_cast(suggestions.size()); +bool LLSpellChecker::isAccepted(const std::string& word) const +{ + if (mAcceptedWords.empty()) + { + return false; + } + std::string word_lower(word); + LLStringUtil::toLower(word_lower); + return (mAcceptedWords.find(word_lower) != mAcceptedWords.end()); } const LLSD LLSpellChecker::getDictionaryData(const std::string& dict_language) @@ -119,7 +217,6 @@ void LLSpellChecker::setDictionaryData(const LLSD& dict_info) return; } -// static void LLSpellChecker::refreshDictionaryMap() { const std::string app_path = getDictionaryAppPath(); @@ -158,15 +255,41 @@ void LLSpellChecker::refreshDictionaryMap() custom_file.close(); } - // Look for installed dictionaries - std::string tmp_app_path, tmp_user_path; + // Determine which dictionaries are usable. Primary dictionaries are resolved by the active + // engine in a single batch (file presence for Hunspell, OS language availability for the native + // backends, each of which queries the OS at most once); non-primary glossaries (e.g. the SL + // glossary) are detected purely by file presence. + std::vector primary_names; + for (LLSD::array_const_iterator it = mDictMap.beginArray(); it != mDictMap.endArray(); ++it) + { + const LLSD& sdDict = *it; + if ((sdDict.has("name")) && (sdDict["is_primary"].asBoolean())) + { + primary_names.push_back(sdDict["name"].asString()); + } + } + const std::set installed_primary = + (mEngine) ? mEngine->getInstalledLanguages(primary_names) : std::set(); + for (LLSD::array_iterator it = mDictMap.beginArray(); it != mDictMap.endArray(); ++it) { LLSD& sdDict = *it; - tmp_app_path = (sdDict.has("name")) ? app_path + sdDict["name"].asString() : LLStringUtil::null; - tmp_user_path = (sdDict.has("name")) ? user_path + sdDict["name"].asString() : LLStringUtil::null; - sdDict["installed"] = - (!tmp_app_path.empty()) && ((gDirUtilp->fileExists(tmp_user_path + ".dic")) || (gDirUtilp->fileExists(tmp_app_path + ".dic"))); + if (!sdDict.has("name")) + { + sdDict["installed"] = false; + continue; + } + + const std::string name = sdDict["name"].asString(); + if (sdDict["is_primary"].asBoolean()) + { + sdDict["installed"] = (installed_primary.find(name) != installed_primary.end()); + } + else + { + sdDict["installed"] = + (gDirUtilp->fileExists(user_path + name + ".dic")) || (gDirUtilp->fileExists(app_path + name + ".dic")); + } } sSettingsChangeSignal(); @@ -174,12 +297,15 @@ void LLSpellChecker::refreshDictionaryMap() void LLSpellChecker::addToCustomDictionary(const std::string& word) { - if (mHunspell) + std::string word_lower(word); + LLStringUtil::toLower(word_lower); + if (!mAcceptedWords.contains(word_lower)) { - mHunspell->add(word); + addToDictFile(getDictionaryUserPath() + DICT_FILE_CUSTOM, word); + + mAcceptedWords.insert(word_lower); + sSettingsChangeSignal(); } - addToDictFile(getDictionaryUserPath() + DICT_FILE_CUSTOM, word); - sSettingsChangeSignal(); } void LLSpellChecker::addToIgnoreList(const std::string& word) @@ -190,6 +316,7 @@ void LLSpellChecker::addToIgnoreList(const std::string& word) { mIgnoreList.push_back(word_lower); addToDictFile(getDictionaryUserPath() + DICT_FILE_IGNORE, word_lower); + mAcceptedWords.insert(word_lower); sSettingsChangeSignal(); } } @@ -197,30 +324,7 @@ void LLSpellChecker::addToIgnoreList(const std::string& word) void LLSpellChecker::addToDictFile(const std::string& dict_path, const std::string& word) { std::vector word_list; - - if (gDirUtilp->fileExists(dict_path)) - { - llifstream file_in(dict_path.c_str(), std::ios::in); - if (file_in.is_open()) - { - std::string word; int line_num = 0; - while (getline(file_in, word)) - { - // Skip over the first line since that's just a line count - if (0 != line_num) - { - word_list.push_back(word); - } - line_num++; - } - } - else - { - // TODO: show error message? - return; - } - } - + readDictWords(dict_path, word_list); word_list.push_back(word); llofstream file_out(dict_path.c_str(), std::ios::out | std::ios::trunc); @@ -249,128 +353,88 @@ void LLSpellChecker::setSecondaryDictionaries(dict_list_t dict_list) return; } - // Check if we're only adding secondary dictionaries, or removing them - dict_list_t dict_add(llmax(dict_list.size(), mDictSecondary.size())), dict_rem(llmax(dict_list.size(), mDictSecondary.size())); - dict_list.sort(); - mDictSecondary.sort(); - dict_list_t::iterator end_added = std::set_difference(dict_list.begin(), dict_list.end(), mDictSecondary.begin(), mDictSecondary.end(), dict_add.begin()); - dict_list_t::iterator end_removed = std::set_difference(mDictSecondary.begin(), mDictSecondary.end(), dict_list.begin(), dict_list.end(), dict_rem.begin()); - - if (end_removed != dict_rem.begin()) // We can't remove secondary dictionaries so we need to recreate the Hunspell instance + if (mDictSecondary == dict_list) { - mDictSecondary = dict_list; - - std::string dict_language = mDictLanguage; - initHunspell(dict_language); + return; } - else if (end_added != dict_add.begin()) // Add the new secondary dictionaries one by one - { - const std::string app_path = getDictionaryAppPath(); - const std::string user_path = getDictionaryUserPath(); - for (dict_list_t::const_iterator it_added = dict_add.begin(); it_added != end_added; ++it_added) - { - const LLSD dict_entry = getDictionaryData(*it_added); - if ( (!dict_entry.isDefined()) || (!dict_entry["installed"].asBoolean()) ) - { - continue; - } - const std::string strFileDic = dict_entry["name"].asString() + ".dic"; - if (gDirUtilp->fileExists(user_path + strFileDic)) - { - mHunspell->add_dic((user_path + strFileDic).c_str()); - } - else if (gDirUtilp->fileExists(app_path + strFileDic)) - { - mHunspell->add_dic((app_path + strFileDic).c_str()); - } - } - mDictSecondary = dict_list; - sSettingsChangeSignal(); - } + // Secondary dictionaries are accepted-word sources (not separate engine languages), so a change + // just rebuilds the accepted-word set - no need to recreate the engine. + mDictSecondary = dict_list; + rebuildAcceptedWords(); + sSettingsChangeSignal(); } -void LLSpellChecker::initHunspell(const std::string& dict_language) +void LLSpellChecker::rebuildAcceptedWords() { - if (mHunspell) + mIgnoreList.clear(); + mAcceptedWords.clear(); + + const std::string app_path = getDictionaryAppPath(); + const std::string user_path = getDictionaryUserPath(); + + // Session ignore list (user_ignore.dic) - kept lowercased for dedup and accepted-word lookup. + std::vector ignore_words; + readDictWords(user_path + DICT_FILE_IGNORE, ignore_words); + for (std::string& word : ignore_words) { - mHunspell.reset(); - mDictLanguage.clear(); - mDictFile.clear(); - mIgnoreList.clear(); + LLStringUtil::toLower(word); + mIgnoreList.push_back(word); + mAcceptedWords.insert(word); } - const LLSD dict_entry = (!dict_language.empty()) ? getDictionaryData(dict_language) : LLSD(); - if ( (!dict_entry.isDefined()) || (!dict_entry["installed"].asBoolean()) || (!dict_entry["is_primary"].asBoolean())) + // User custom dictionary (user_custom.dic). + std::vector custom_words; + readDictWords(user_path + DICT_FILE_CUSTOM, custom_words); + for (std::string& word : custom_words) { - sSettingsChangeSignal(); - return; + LLStringUtil::toLower(word); + mAcceptedWords.insert(word); } - const std::string app_path = getDictionaryAppPath(); - const std::string user_path = getDictionaryUserPath(); - if (dict_entry.has("name")) + // Secondary dictionaries (e.g. the SL glossary "sl") contribute their whole word list. + for (const std::string& dict_language : mDictSecondary) { - const std::string filename_aff = dict_entry["name"].asString() + ".aff"; - const std::string filename_dic = dict_entry["name"].asString() + ".dic"; - if ( (gDirUtilp->fileExists(user_path + filename_aff)) && (gDirUtilp->fileExists(user_path + filename_dic)) ) + const LLSD dict_entry = getDictionaryData(dict_language); + if ( (!dict_entry.isDefined()) || (!dict_entry["installed"].asBoolean()) || (!dict_entry.has("name")) ) { - mHunspell = std::make_unique((user_path + filename_aff).c_str(), (user_path + filename_dic).c_str()); + continue; } - else if ( (gDirUtilp->fileExists(app_path + filename_aff)) && (gDirUtilp->fileExists(app_path + filename_dic)) ) + + const std::string filename_dic = dict_entry["name"].asString() + ".dic"; + std::vector words; + if (gDirUtilp->fileExists(user_path + filename_dic)) { - mHunspell = std::make_unique((app_path + filename_aff).c_str(), (app_path + filename_dic).c_str()); + readDictWords(user_path + filename_dic, words); } - if (!mHunspell) + else if (gDirUtilp->fileExists(app_path + filename_dic)) { - return; + readDictWords(app_path + filename_dic, words); } - - mDictLanguage = dict_language; - mDictFile = dict_entry["name"].asString(); - - if (gDirUtilp->fileExists(user_path + DICT_FILE_CUSTOM)) + for (std::string& word : words) { - mHunspell->add_dic((user_path + DICT_FILE_CUSTOM).c_str()); + LLStringUtil::toLower(word); + mAcceptedWords.insert(word); } + } +} - if (gDirUtilp->fileExists(user_path + DICT_FILE_IGNORE)) - { - llifstream file_in((user_path + DICT_FILE_IGNORE).c_str(), std::ios::in); - if (file_in.is_open()) - { - std::string word; int idxLine = 0; - while (getline(file_in, word)) - { - // Skip over the first line since that's just a line count - if (0 != idxLine) - { - LLStringUtil::toLower(word); - mIgnoreList.push_back(word); - } - idxLine++; - } - } - } +void LLSpellChecker::activateDictionary(const std::string& dict_language) +{ + mDictLanguage.clear(); + mIgnoreList.clear(); + mAcceptedWords.clear(); - for (dict_list_t::const_iterator it = mDictSecondary.begin(); it != mDictSecondary.end(); ++it) - { - const LLSD dict_entry = getDictionaryData(*it); - if ( (!dict_entry.isDefined()) || (!dict_entry["installed"].asBoolean()) ) - { - continue; - } + const LLSD dict_entry = (!dict_language.empty()) ? getDictionaryData(dict_language) : LLSD(); + const bool usable = + (dict_entry.isDefined()) && (dict_entry["installed"].asBoolean()) && (dict_entry["is_primary"].asBoolean()); + // An empty name deactivates the engine; otherwise it activates the named primary dictionary. + const std::string name = (usable) ? dict_entry["name"].asString() : std::string(); - const std::string filename_dic = dict_entry["name"].asString() + ".dic"; - if (gDirUtilp->fileExists(user_path + filename_dic)) - { - mHunspell->add_dic((user_path + filename_dic).c_str()); - } - else if (gDirUtilp->fileExists(app_path + filename_dic)) - { - mHunspell->add_dic((app_path + filename_dic).c_str()); - } - } + if ( (mEngine) && (mEngine->setLanguage(name)) ) + { + mDictLanguage = dict_language; + rebuildAcceptedWords(); } sSettingsChangeSignal(); @@ -394,12 +458,13 @@ const std::string LLSpellChecker::getDictionaryUserPath() // static bool LLSpellChecker::getUseSpellCheck() { - return (LLSpellChecker::instanceExists()) && (LLSpellChecker::instance().mHunspell); + return (LLSpellChecker::instanceExists()) && (LLSpellChecker::instance().mEngine) && (LLSpellChecker::instance().mEngine->isActive()); } bool LLSpellChecker::canRemoveDictionary(const std::string& dict_language) { - // Only user-installed inactive dictionaries can be removed + // Only user-installed inactive dictionaries can be removed (native backends never set + // "user_installed", so removal is naturally unavailable for system spell-check languages). const LLSD dict_info = getDictionaryData(dict_language); return (dict_info["user_installed"].asBoolean()) && @@ -475,6 +540,6 @@ void LLSpellChecker::setUseSpellCheck(const std::string& dict_language) if ( (((dict_language.empty()) && (getUseSpellCheck())) || (!dict_language.empty())) && (LLSpellChecker::instance().mDictLanguage != dict_language) ) { - LLSpellChecker::instance().initHunspell(dict_language); + LLSpellChecker::instance().activateDictionary(dict_language); } } diff --git a/indra/llui/llspellcheck.h b/indra/llui/llspellcheck.h index 9df2f94085..c74ab160e2 100644 --- a/indra/llui/llspellcheck.h +++ b/indra/llui/llspellcheck.h @@ -31,14 +31,19 @@ #include "llui.h" #include "llinitdestroyclass.h" #include +#include +#include -class Hunspell; +// The active platform spell-check backend (Hunspell, NSSpellChecker, or the Windows Spell Checking +// API), selected at build time. LLSpellChecker owns one via a pImpl and keeps all backend-agnostic +// logic itself, so this header stays platform-clean. +class LLSpellCheckEngine; class LLSpellChecker : public LLSimpleton { public: LLSpellChecker(); - ~LLSpellChecker(); + ~LLSpellChecker(); // defined in llspellcheck.cpp (pImpl: unique_ptr) void addToCustomDictionary(const std::string& word); void addToIgnoreList(const std::string& word); @@ -46,7 +51,9 @@ class LLSpellChecker : public LLSimpleton S32 getSuggestions(const std::string& word, std::vector& suggestions) const; protected: void addToDictFile(const std::string& dict_path, const std::string& word); - void initHunspell(const std::string& dict_language); + void activateDictionary(const std::string& dict_language); // (re)activate the engine + accepted words + void rebuildAcceptedWords(); // reload custom + ignore + secondary words + bool isAccepted(const std::string& word) const; // case-insensitive accepted-word lookup public: typedef std::list dict_list_t; @@ -76,11 +83,11 @@ class LLSpellChecker : public LLSimpleton static boost::signals2::connection setSettingsChangeCallback(const settings_change_signal_t::slot_type& cb); protected: - std::unique_ptr mHunspell; - std::string mDictLanguage; - std::string mDictFile; - dict_list_t mDictSecondary; - std::vector mIgnoreList; + std::unique_ptr mEngine; // active platform backend + std::string mDictLanguage; // active primary dictionary (display name) + dict_list_t mDictSecondary; // active secondary dictionaries (e.g. SL glossary) + std::vector mIgnoreList; // session ignore list, lowercased (user_ignore.dic) + std::unordered_set mAcceptedWords; // lowercased custom + ignore + secondary words LLSD mDictMap; static settings_change_signal_t sSettingsChangeSignal; diff --git a/indra/llui/llspellcheckengine.h b/indra/llui/llspellcheckengine.h new file mode 100644 index 0000000000..2df108d938 --- /dev/null +++ b/indra/llui/llspellcheckengine.h @@ -0,0 +1,79 @@ +/** + * @file llspellcheckengine.h + * @brief Abstract spell-check engine backend interface + * + * $LicenseInfo:firstyear=2001&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2010, Linden Research, Inc. + * + * 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 + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#ifndef LLSPELLCHECKENGINE_H +#define LLSPELLCHECKENGINE_H + +#include "stdtypes.h" + +#include +#include +#include +#include + +// Platform spell-check backend used by LLSpellChecker. An engine only knows how to check and +// suggest words for a single active primary language, addressed by the viewer dictionary basename +// (e.g. "en_us"); everything else (dictionary metadata, the custom/ignore/glossary "accepted word" +// set, persistence, and the settings-change signal) lives in LLSpellChecker. Exactly one concrete +// engine is compiled per build (selected by CMake), and it provides the create() factory. +class LLSpellCheckEngine +{ +public: + virtual ~LLSpellCheckEngine() = default; + + // Activate the primary dictionary named by 'name' (a dictionaries.xml basename, e.g. "en_us"). + // Passing an empty string deactivates the engine. Returns true if a usable checker is active. + virtual bool setLanguage(const std::string& name) = 0; + + // True when a usable language checker is currently active. + virtual bool isActive() const = 0; + + // True if the word is spelled correctly according to the active language. Only called when the + // engine is active and the word is at least 3 characters; the custom/ignore/glossary words are + // handled by LLSpellChecker, not here. + virtual bool checkWord(const std::string& word) const = 0; + + // Fill 'suggestions' with replacement candidates for a misspelled word; returns the count. + virtual S32 getSuggestions(const std::string& word, std::vector& suggestions) const = 0; + + // Of the given candidate primary dictionary names, return those this engine can spell-check on + // this system. The engine performs any (potentially expensive) OS query once for the whole + // batch, so LLSpellChecker::refreshDictionaryMap() resolves all primaries in a single call. + virtual std::set getInstalledLanguages(const std::vector& candidate_names) const = 0; + + // Construct the platform engine compiled into this build (Hunspell, NSSpellChecker, or the + // Windows Spell Checking API). Defined in the selected engine translation unit. + static std::unique_ptr create(); + + // Shared helper for the OS-list engines (NSSpellChecker, Windows): match a viewer dictionary + // basename (e.g. "en_us") against the platform's available locale tags, joining language and + // region with 'separator' ('_' for NSSpellChecker, '-' for BCP-47). Three passes: an exact + // region-qualified match, then the bare language, then any installed variant of the same + // language. Returns the matched platform tag (preserving the platform's casing) or "" if none. + static std::string matchLanguage(const std::string& name, const std::vector& available_tags, char separator); +}; + +#endif // LLSPELLCHECKENGINE_H diff --git a/indra/llui/llspellcheckengine_hunspell.cpp b/indra/llui/llspellcheckengine_hunspell.cpp new file mode 100644 index 0000000000..e3c5cc4911 --- /dev/null +++ b/indra/llui/llspellcheckengine_hunspell.cpp @@ -0,0 +1,113 @@ +/** + * @file llspellcheckengine_hunspell.cpp + * @brief Hunspell spell-check engine backend (default, cross-platform) + * + * $LicenseInfo:firstyear=2001&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2010, Linden Research, Inc. + * + * 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 + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lldir.h" +#include "llspellcheck.h" +#include "llspellcheckengine.h" + +#include + +#include + +namespace +{ + class LLHunspellEngine final : public LLSpellCheckEngine + { + public: + bool setLanguage(const std::string& name) override; + bool isActive() const override { return (bool)mHunspell; } + bool checkWord(const std::string& word) const override; + S32 getSuggestions(const std::string& word, std::vector& suggestions) const override; + std::set getInstalledLanguages(const std::vector& candidate_names) const override; + private: + std::unique_ptr mHunspell; + }; + + bool LLHunspellEngine::setLanguage(const std::string& name) + { + mHunspell.reset(); + if (name.empty()) + { + return false; + } + + const std::string app_path = LLSpellChecker::getDictionaryAppPath(); + const std::string user_path = LLSpellChecker::getDictionaryUserPath(); + const std::string filename_aff = name + ".aff"; + const std::string filename_dic = name + ".dic"; + + if ( (gDirUtilp->fileExists(user_path + filename_aff)) && (gDirUtilp->fileExists(user_path + filename_dic)) ) + { + mHunspell = std::make_unique((user_path + filename_aff).c_str(), (user_path + filename_dic).c_str()); + } + else if ( (gDirUtilp->fileExists(app_path + filename_aff)) && (gDirUtilp->fileExists(app_path + filename_dic)) ) + { + mHunspell = std::make_unique((app_path + filename_aff).c_str(), (app_path + filename_dic).c_str()); + } + + return (bool)mHunspell; + } + + bool LLHunspellEngine::checkWord(const std::string& word) const + { + return (mHunspell) && (0 != mHunspell->spell(word)); + } + + S32 LLHunspellEngine::getSuggestions(const std::string& word, std::vector& suggestions) const + { + suggestions.clear(); + if (!mHunspell) + { + return 0; + } + suggestions = mHunspell->suggest(word); + return static_cast(suggestions.size()); + } + + std::set LLHunspellEngine::getInstalledLanguages(const std::vector& candidate_names) const + { + // Hunspell dictionaries are plain files, so availability is simply file presence. + std::set installed; + const std::string app_path = LLSpellChecker::getDictionaryAppPath(); + const std::string user_path = LLSpellChecker::getDictionaryUserPath(); + for (const std::string& name : candidate_names) + { + if ( (gDirUtilp->fileExists(user_path + name + ".dic") && gDirUtilp->fileExists(user_path + name + ".aff")) || (gDirUtilp->fileExists(app_path + name + ".dic") && gDirUtilp->fileExists(app_path + name + ".aff")) ) + { + installed.insert(name); + } + } + return installed; + } +} + +// static +std::unique_ptr LLSpellCheckEngine::create() +{ + return std::make_unique(); +} diff --git a/indra/llui/llspellcheckengine_mac.mm b/indra/llui/llspellcheckengine_mac.mm new file mode 100644 index 0000000000..6936d626f3 --- /dev/null +++ b/indra/llui/llspellcheckengine_mac.mm @@ -0,0 +1,191 @@ +/** + * @file llspellcheckengine_mac.mm + * @brief Spell-check engine backed by the native macOS NSSpellChecker + * + * $LicenseInfo:firstyear=2001&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2010, Linden Research, Inc. + * + * 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 + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llspellcheckengine.h" + +#import + +namespace +{ + NSSpellChecker* nsChecker() + { + return [NSSpellChecker sharedSpellChecker]; + } + + // std::string (UTF-8) -> NSString. Returns nil on invalid UTF-8 (callers must guard). + NSString* toNS(const std::string& str) + { + return [NSString stringWithUTF8String:str.c_str()]; + } + + std::string fromNS(NSString* str) + { + if (!str) + { + return std::string(); + } + const char* utf8 = [str UTF8String]; + return (utf8) ? std::string(utf8) : std::string(); + } + + class LLNSSpellEngine final : public LLSpellCheckEngine + { + public: + bool setLanguage(const std::string& name) override; + bool isActive() const override { return mEnabled; } + bool checkWord(const std::string& word) const override; + S32 getSuggestions(const std::string& word, std::vector& suggestions) const override; + std::set getInstalledLanguages(const std::vector& candidate_names) const override; + private: + // The spell checker's installed language codes (e.g. "en_US"), as UTF-8 strings. + static std::vector availableTags(); + + bool mEnabled = false; + std::string mNSLanguage; // Resolved NSSpellChecker language code (e.g. "en_US") + }; + + // static + std::vector LLNSSpellEngine::availableTags() + { + std::vector tags; + @autoreleasepool + { + for (NSString* lang in [nsChecker() availableLanguages]) + { + tags.push_back(fromNS(lang)); + } + } + return tags; + } + + bool LLNSSpellEngine::setLanguage(const std::string& name) + { + llassert([NSThread isMainThread]); + mEnabled = false; + mNSLanguage.clear(); + if (name.empty()) + { + return false; + } + + const std::string ns_lang = LLSpellCheckEngine::matchLanguage(name, availableTags(), '_'); + if (ns_lang.empty()) + { + return false; + } + + @autoreleasepool + { + NSString* ns_lang_str = toNS(ns_lang); + // setLanguage: returns NO if the language can't be matched; treat that as disabled. + if ( (!ns_lang_str) || (![nsChecker() setLanguage:ns_lang_str]) ) + { + return false; + } + } + mNSLanguage = ns_lang; + mEnabled = true; + return true; + } + + bool LLNSSpellEngine::checkWord(const std::string& word) const + { + // NSSpellChecker is AppKit; use it only on the main thread (where text widgets lay out). + llassert([NSThread isMainThread]); + if (!mEnabled) + { + return true; + } + + @autoreleasepool + { + NSString* ns_word = toNS(word); + if (!ns_word) + { + return true; + } + NSRange range = [nsChecker() checkSpellingOfString:ns_word + startingAt:0 + language:toNS(mNSLanguage) + wrap:NO + inSpellDocumentWithTag:0 + wordCount:NULL]; + return (range.location == NSNotFound); + } + } + + S32 LLNSSpellEngine::getSuggestions(const std::string& word, std::vector& suggestions) const + { + llassert([NSThread isMainThread]); + suggestions.clear(); + if (!mEnabled) + { + return 0; + } + + @autoreleasepool + { + NSString* ns_word = toNS(word); + if (!ns_word) + { + return 0; + } + // The range must be expressed in NSString (UTF-16) units, not std::string bytes. + NSArray* guesses = [nsChecker() guessesForWordRange:NSMakeRange(0, [ns_word length]) + inString:ns_word + language:toNS(mNSLanguage) + inSpellDocumentWithTag:0]; + for (NSString* guess in guesses) + { + suggestions.push_back(fromNS(guess)); + } + } + return static_cast(suggestions.size()); + } + + std::set LLNSSpellEngine::getInstalledLanguages(const std::vector& candidate_names) const + { + llassert([NSThread isMainThread]); + std::set installed; + const std::vector available = availableTags(); // one OS query for the batch + for (const std::string& name : candidate_names) + { + if (!LLSpellCheckEngine::matchLanguage(name, available, '_').empty()) + { + installed.insert(name); + } + } + return installed; + } +} + +// static +std::unique_ptr LLSpellCheckEngine::create() +{ + return std::make_unique(); +} diff --git a/indra/llui/llspellcheckengine_win32.cpp b/indra/llui/llspellcheckengine_win32.cpp new file mode 100644 index 0000000000..070dd05e19 --- /dev/null +++ b/indra/llui/llspellcheckengine_win32.cpp @@ -0,0 +1,262 @@ +/** + * @file llspellcheckengine_win32.cpp + * @brief Spell-check engine backed by the native Windows Spell Checking API + * + * $LicenseInfo:firstyear=2001&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2010, Linden Research, Inc. + * + * 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 + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llstring.h" +#include "llthread.h" + +#include "llspellcheckengine.h" + +#include +#include + +#include +#include + +namespace +{ + // std::string (UTF-8) -> std::wstring (UTF-16). The default code page is CP_UTF8. + std::wstring toWide(const std::string& str) + { + return ll_convert_string_to_wide(str.c_str(), str.length()); + } + + // A null-terminated wide string (e.g. an LPOLESTR returned by COM) -> UTF-8 std::string. + std::string fromWide(const wchar_t* wstr) + { + return (wstr) ? ll_convert_wide_to_string(wstr, wcslen(wstr)) : std::string(); + } + + class LLWinSpellEngine final : public LLSpellCheckEngine + { + public: + LLWinSpellEngine(); + ~LLWinSpellEngine() override; + bool setLanguage(const std::string& name) override; + bool isActive() const override { return (nullptr != mChecker); } + bool checkWord(const std::string& word) const override; + S32 getSuggestions(const std::string& word, std::vector& suggestions) const override; + std::set getInstalledLanguages(const std::vector& candidate_names) const override; + private: + // The factory's supported language tags (e.g. "en-US"), as UTF-8 strings. + std::vector availableTags() const; + + ISpellCheckerFactory* mFactory = nullptr; + ISpellChecker* mChecker = nullptr; + HRESULT mComInitResult = E_FAIL; // gates the matching CoUninitialize + }; + + LLWinSpellEngine::LLWinSpellEngine() + { + llassert(on_main_thread()); + + // The Windows Spell Checking API is COM-based and the viewer keeps no standing COM apartment + // on the main thread, so own one for this engine's lifetime. Apartment-threaded (STA) matches + // every existing scoped COM site and is reference-counted, so it nests safely. + mComInitResult = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (SUCCEEDED(mComInitResult) || (RPC_E_CHANGED_MODE == mComInitResult)) + { + if (FAILED(CoCreateInstance(__uuidof(SpellCheckerFactory), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&mFactory)))) + { + mFactory = nullptr; + } + } + } + + LLWinSpellEngine::~LLWinSpellEngine() + { + llassert(on_main_thread()); + + // Release COM interfaces before tearing down the apartment. + if (mChecker) + { + mChecker->Release(); + mChecker = nullptr; + } + if (mFactory) + { + mFactory->Release(); + mFactory = nullptr; + } + // Balance CoInitializeEx only when we actually initialized (S_OK or S_FALSE); never on + // RPC_E_CHANGED_MODE (a thread already in another apartment that we did not initialize). + if (SUCCEEDED(mComInitResult)) + { + CoUninitialize(); + } + } + + std::vector LLWinSpellEngine::availableTags() const + { + std::vector tags; + if (!mFactory) + { + return tags; + } + IEnumString* langs = nullptr; + if (SUCCEEDED(mFactory->get_SupportedLanguages(&langs)) && langs) + { + LPOLESTR psz = nullptr; + ULONG fetched = 0; + while ((langs->Next(1, &psz, &fetched) == S_OK) && (1 == fetched) && psz) + { + tags.push_back(fromWide(psz)); + CoTaskMemFree(psz); + psz = nullptr; + } + langs->Release(); + } + return tags; + } + + bool LLWinSpellEngine::setLanguage(const std::string& name) + { + llassert(on_main_thread()); + if (mChecker) + { + mChecker->Release(); + mChecker = nullptr; + } + if ( (!mFactory) || (name.empty()) ) + { + return false; + } + + const std::string tag = LLSpellCheckEngine::matchLanguage(name, availableTags(), '-'); + if (tag.empty()) + { + return false; + } + + const std::wstring wtag = toWide(tag); + // CreateSpellChecker returns E_INVALIDARG when no checker is available for the language. + if (FAILED(mFactory->CreateSpellChecker(wtag.c_str(), &mChecker)) || (!mChecker)) + { + mChecker = nullptr; + return false; + } + return true; + } + + bool LLWinSpellEngine::checkWord(const std::string& word) const + { + llassert(on_main_thread()); + if (!mChecker) + { + return true; + } + + const std::wstring wword = toWide(word); + if (wword.empty()) + { + return true; + } + + IEnumSpellingError* errors = nullptr; + if (FAILED(mChecker->Check(wword.c_str(), &errors)) || (!errors)) + { + return true; + } + + // A correctly spelled word yields an empty enumeration: Next() returns S_FALSE. Any fetched + // ISpellingError (S_OK) means the word is misspelled. + ISpellingError* error = nullptr; + const bool misspelled = (S_OK == errors->Next(&error)) && (nullptr != error); + if (error) + { + error->Release(); + } + errors->Release(); + + return !misspelled; + } + + S32 LLWinSpellEngine::getSuggestions(const std::string& word, std::vector& suggestions) const + { + llassert(on_main_thread()); + suggestions.clear(); + if (!mChecker) + { + return 0; + } + + const std::wstring wword = toWide(word); + if (wword.empty()) + { + return 0; + } + + IEnumString* enum_str = nullptr; + const HRESULT hr = mChecker->Suggest(wword.c_str(), &enum_str); + if (FAILED(hr) || (!enum_str)) + { + return 0; + } + + // Suggest() returns S_FALSE when the word is already spelled correctly, in which case the + // enumeration just echoes the input word - skip it so we don't offer the word as its own fix. + if (S_OK == hr) + { + LPOLESTR psz = nullptr; + ULONG fetched = 0; + while ((enum_str->Next(1, &psz, &fetched) == S_OK) && (1 == fetched) && psz) + { + suggestions.push_back(fromWide(psz)); + CoTaskMemFree(psz); + psz = nullptr; + } + } + enum_str->Release(); + + return static_cast(suggestions.size()); + } + + std::set LLWinSpellEngine::getInstalledLanguages(const std::vector& candidate_names) const + { + llassert(on_main_thread()); + std::set installed; + if (!mFactory) + { + return installed; + } + const std::vector available = availableTags(); // one OS query for the batch + for (const std::string& name : candidate_names) + { + if (!LLSpellCheckEngine::matchLanguage(name, available, '-').empty()) + { + installed.insert(name); + } + } + return installed; + } +} + +// static +std::unique_ptr LLSpellCheckEngine::create() +{ + return std::make_unique(); +} diff --git a/indra/vcpkg.json b/indra/vcpkg.json index 7c7cea027f..faa62f90dc 100644 --- a/indra/vcpkg.json +++ b/indra/vcpkg.json @@ -50,7 +50,6 @@ }, "glm", "harfbuzz", - "hunspell", { "name": "libavif", "features": [ @@ -197,6 +196,12 @@ "faudio" ] }, + "hunspell": { + "description": "Build with support for Hunspell spell-check engine", + "dependencies": [ + "hunspell" + ] + }, "kdu": { "description": "Build with support for Kakadu JPEG2000", "dependencies": [