Skip to content

Commit aaa72f0

Browse files
RyeMuttclaude
andcommitted
Add build-time-selectable Windows Spell Checking API engine
Add USE_WINSPELLCHECK (WIN32-only, default OFF) which compiles llspellcheckengine_win32.cpp instead of the Hunspell engine and links ole32. The engine owns an apartment-threaded COM apartment for its lifetime, creates an ISpellCheckerFactory, maps the viewer's dictionary names to BCP-47 language tags (via the shared LLSpellCheckEngine::matchLanguage helper), and answers checkWord/getSuggestions via ISpellChecker (Check + IEnumSpellingError, Suggest with S_FALSE treated as no suggestions). As with the macOS engine, the SL glossary and user words live in LLSpellChecker's accepted-word set, and include(Hunspell) is skipped for a Windows-native build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14bdae0 commit aaa72f0

7 files changed

Lines changed: 306 additions & 12 deletions

File tree

indra/CMakeLists.txt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ endif ()
7878

7979
# Spell checking backend
8080
cmake_dependent_option(USE_NSSPELLCHECKER "Use the native macOS NSSpellChecker instead of Hunspell" OFF "DARWIN" OFF)
81+
cmake_dependent_option(USE_WINSPELLCHECK "Use the native Windows Spell Checking API instead of Hunspell" OFF "WIN32" OFF)
8182

8283
# Configure crash reporting
8384
option(RELEASE_CRASH_REPORTING "Enable use of crash reporting in release builds" OFF)
@@ -184,6 +185,10 @@ if(BUILD_DULLAHAN_EXAMPLE)
184185
list(APPEND VCPKG_MANIFEST_FEATURES "dullahan-example")
185186
endif()
186187

188+
if (NOT USE_NSSPELLCHECKER AND NOT USE_WINSPELLCHECK)
189+
list(APPEND VCPKG_MANIFEST_FEATURES "hunspell")
190+
endif()
191+
187192
# Export compile_commands.json for ninja and makefile generators
188193
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
189194

@@ -270,9 +275,7 @@ include(GLIB)
270275
include(GLM)
271276
include(Harfbuzz)
272277
include(Havok)
273-
if (NOT USE_NSSPELLCHECKER)
274-
include(Hunspell) # only the default (Hunspell) spell-check engine needs it
275-
endif ()
278+
include(Hunspell)
276279
include(JPEG)
277280
include(LLKDU)
278281
include(LLPhysicsExtensions)
@@ -303,6 +306,7 @@ include(Vorbis)
303306
include(WebP)
304307
include(WebRTC)
305308
include(websocketpp)
309+
include(WinSpellCheck)
306310
include(xxHash)
307311
include(ZLIBNG)
308312

indra/cmake/Copy3rdPartyLibs.cmake

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ if(WINDOWS)
8888
endif()
8989
elseif(DARWIN)
9090
set(vcpkg_lib_dir "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib")
91-
set(release_libs
92-
"libhunspell-1.7.0.dylib"
93-
)
91+
if (NOT USE_NSSPELLCHECKER)
92+
set(release_libs "")
93+
else()
94+
set(release_libs
95+
"libhunspell-1.7.0.dylib"
96+
)
97+
endif()
9498
elseif(LINUX)
9599
set(vcpkg_lib_dir "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib")
96100
set(release_libs "")

indra/cmake/Hunspell.cmake

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ include_guard()
33

44
add_library(ll::hunspell INTERFACE IMPORTED)
55

6-
find_package(PkgConfig REQUIRED)
6+
if (NOT USE_NSSPELLCHECKER AND NOT USE_WINSPELLCHECK)
7+
find_package(PkgConfig REQUIRED)
78

8-
pkg_check_modules(hunspell REQUIRED IMPORTED_TARGET GLOBAL hunspell)
9-
target_link_libraries(ll::hunspell INTERFACE PkgConfig::hunspell)
9+
pkg_check_modules(hunspell REQUIRED IMPORTED_TARGET GLOBAL hunspell)
10+
target_link_libraries(ll::hunspell INTERFACE PkgConfig::hunspell)
11+
endif()

indra/cmake/WinSpellCheck.cmake

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# -*- cmake -*-
2+
3+
include_guard()
4+
5+
if (USE_WINSPELLCHECK)
6+
# Link target for the Windows Spell Checking engine (llspellcheckengine_win32.cpp). ole32 provides
7+
# CoInitializeEx/CoCreateInstance/CoTaskMemFree/CoUninitialize; <spellcheck.h> ships with the
8+
# Windows SDK and the API uses LPCWSTR (not BSTR), so no oleaut32/uuid.lib is required. No compile
9+
# definitions are needed: the engine is selected by which source CMake compiles, and the shared
10+
# llspellcheck.h is platform-clean (it only forward-declares the abstract LLSpellCheckEngine).
11+
add_library(ll::winspellcheck INTERFACE IMPORTED)
12+
target_link_libraries(ll::winspellcheck INTERFACE ole32)
13+
endif ()

indra/llui/CMakeLists.txt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,16 @@ target_sources(llui
234234
llxyvector.h
235235
)
236236

237-
# Spell checking: the backend-agnostic LLSpellChecker core (always compiled) plus exactly one
238-
# platform engine selected by build option — native macOS NSSpellChecker or Hunspell (default).
237+
# Spell checking: the backend-agnostic LLSpellChecker core (llspellcheck.cpp) is always compiled;
238+
# exactly one platform engine is selected by build option — native macOS NSSpellChecker
239+
# (Objective-C++), native Windows Spell Checking API, or Hunspell (default).
239240
target_sources(llui PRIVATE llspellcheck.cpp)
240241
if (USE_NSSPELLCHECKER)
241242
target_sources(llui PRIVATE llspellcheckengine_mac.mm)
242243
set_source_files_properties(llspellcheckengine_mac.mm PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE)
244+
elseif (USE_WINSPELLCHECK)
245+
target_sources(llui PRIVATE llspellcheckengine_win32.cpp)
246+
set_source_files_properties(llspellcheckengine_win32.cpp PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE)
243247
else ()
244248
target_sources(llui PRIVATE llspellcheckengine_hunspell.cpp)
245249
endif ()
@@ -273,6 +277,8 @@ target_link_libraries(llui
273277

274278
if (USE_NSSPELLCHECKER)
275279
target_link_libraries(llui PUBLIC ll::nsspellchecker)
280+
elseif (USE_WINSPELLCHECK)
281+
target_link_libraries(llui PUBLIC ll::winspellcheck)
276282
else ()
277283
target_link_libraries(llui PUBLIC ll::hunspell)
278284
endif ()
@@ -283,6 +289,8 @@ target_precompile_headers(llui REUSE_FROM llprecompiled)
283289
if(BUILD_TESTING)
284290
if (USE_NSSPELLCHECKER)
285291
set(spellcheck_lib ll::nsspellchecker)
292+
elseif (USE_WINSPELLCHECK)
293+
set(spellcheck_lib ll::winspellcheck)
286294
else ()
287295
set(spellcheck_lib ll::hunspell)
288296
endif ()
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* @file llspellcheckengine_win32.cpp
3+
* @brief Spell-check engine backed by the native Windows Spell Checking API
4+
*
5+
* $LicenseInfo:firstyear=2001&license=viewerlgpl$
6+
* Second Life Viewer Source Code
7+
* Copyright (C) 2010, Linden Research, Inc.
8+
*
9+
* This library is free software; you can redistribute it and/or
10+
* modify it under the terms of the GNU Lesser General Public
11+
* License as published by the Free Software Foundation;
12+
* version 2.1 of the License only.
13+
*
14+
* This library is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17+
* Lesser General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Lesser General Public
20+
* License along with this library; if not, write to the Free Software
21+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
22+
*
23+
* Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA
24+
* $/LicenseInfo$
25+
*/
26+
27+
#include "linden_common.h"
28+
29+
#include "llstring.h"
30+
#include "llthread.h"
31+
32+
#include "llspellcheckengine.h"
33+
34+
#include <objbase.h>
35+
#include <spellcheck.h>
36+
37+
#include <cwchar>
38+
#include <string>
39+
40+
namespace
41+
{
42+
// std::string (UTF-8) -> std::wstring (UTF-16). The default code page is CP_UTF8.
43+
std::wstring toWide(const std::string& str)
44+
{
45+
return ll_convert_string_to_wide(str.c_str(), str.length());
46+
}
47+
48+
// A null-terminated wide string (e.g. an LPOLESTR returned by COM) -> UTF-8 std::string.
49+
std::string fromWide(const wchar_t* wstr)
50+
{
51+
return (wstr) ? ll_convert_wide_to_string(wstr, wcslen(wstr)) : std::string();
52+
}
53+
54+
class LLWinSpellEngine final : public LLSpellCheckEngine
55+
{
56+
public:
57+
LLWinSpellEngine();
58+
~LLWinSpellEngine() override;
59+
bool setLanguage(const std::string& name) override;
60+
bool isActive() const override { return (nullptr != mChecker); }
61+
bool checkWord(const std::string& word) const override;
62+
S32 getSuggestions(const std::string& word, std::vector<std::string>& suggestions) const override;
63+
std::set<std::string> getInstalledLanguages(const std::vector<std::string>& candidate_names) const override;
64+
private:
65+
// The factory's supported language tags (e.g. "en-US"), as UTF-8 strings.
66+
std::vector<std::string> availableTags() const;
67+
68+
ISpellCheckerFactory* mFactory = nullptr;
69+
ISpellChecker* mChecker = nullptr;
70+
HRESULT mComInitResult = E_FAIL; // gates the matching CoUninitialize
71+
};
72+
73+
LLWinSpellEngine::LLWinSpellEngine()
74+
{
75+
// The Windows Spell Checking API is COM-based and the viewer keeps no standing COM apartment
76+
// on the main thread, so own one for this engine's lifetime. Apartment-threaded (STA) matches
77+
// every existing scoped COM site and is reference-counted, so it nests safely.
78+
mComInitResult = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
79+
if (SUCCEEDED(mComInitResult) || (RPC_E_CHANGED_MODE == mComInitResult))
80+
{
81+
if (FAILED(CoCreateInstance(__uuidof(SpellCheckerFactory), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&mFactory))))
82+
{
83+
mFactory = nullptr;
84+
}
85+
}
86+
}
87+
88+
LLWinSpellEngine::~LLWinSpellEngine()
89+
{
90+
// Release COM interfaces before tearing down the apartment.
91+
if (mChecker)
92+
{
93+
mChecker->Release();
94+
mChecker = nullptr;
95+
}
96+
if (mFactory)
97+
{
98+
mFactory->Release();
99+
mFactory = nullptr;
100+
}
101+
// Balance CoInitializeEx only when we actually initialized (S_OK or S_FALSE); never on
102+
// RPC_E_CHANGED_MODE (a thread already in another apartment that we did not initialize).
103+
if (SUCCEEDED(mComInitResult))
104+
{
105+
CoUninitialize();
106+
}
107+
}
108+
109+
std::vector<std::string> LLWinSpellEngine::availableTags() const
110+
{
111+
std::vector<std::string> tags;
112+
if (!mFactory)
113+
{
114+
return tags;
115+
}
116+
IEnumString* langs = nullptr;
117+
if (SUCCEEDED(mFactory->get_SupportedLanguages(&langs)) && langs)
118+
{
119+
LPOLESTR psz = nullptr;
120+
ULONG fetched = 0;
121+
while ((langs->Next(1, &psz, &fetched) == S_OK) && (1 == fetched) && psz)
122+
{
123+
tags.push_back(fromWide(psz));
124+
CoTaskMemFree(psz);
125+
psz = nullptr;
126+
}
127+
langs->Release();
128+
}
129+
return tags;
130+
}
131+
132+
bool LLWinSpellEngine::setLanguage(const std::string& name)
133+
{
134+
llassert(on_main_thread());
135+
if (mChecker)
136+
{
137+
mChecker->Release();
138+
mChecker = nullptr;
139+
}
140+
if ( (!mFactory) || (name.empty()) )
141+
{
142+
return false;
143+
}
144+
145+
const std::string tag = LLSpellCheckEngine::matchLanguage(name, availableTags(), '-');
146+
if (tag.empty())
147+
{
148+
return false;
149+
}
150+
151+
const std::wstring wtag = toWide(tag);
152+
// CreateSpellChecker returns E_INVALIDARG when no checker is available for the language.
153+
if (FAILED(mFactory->CreateSpellChecker(wtag.c_str(), &mChecker)) || (!mChecker))
154+
{
155+
mChecker = nullptr;
156+
return false;
157+
}
158+
return true;
159+
}
160+
161+
bool LLWinSpellEngine::checkWord(const std::string& word) const
162+
{
163+
llassert(on_main_thread());
164+
if (!mChecker)
165+
{
166+
return true;
167+
}
168+
169+
const std::wstring wword = toWide(word);
170+
if (wword.empty())
171+
{
172+
return true;
173+
}
174+
175+
IEnumSpellingError* errors = nullptr;
176+
if (FAILED(mChecker->Check(wword.c_str(), &errors)) || (!errors))
177+
{
178+
return true;
179+
}
180+
181+
// A correctly spelled word yields an empty enumeration: Next() returns S_FALSE. Any fetched
182+
// ISpellingError (S_OK) means the word is misspelled.
183+
ISpellingError* error = nullptr;
184+
const bool misspelled = (S_OK == errors->Next(&error)) && (nullptr != error);
185+
if (error)
186+
{
187+
error->Release();
188+
}
189+
errors->Release();
190+
191+
return !misspelled;
192+
}
193+
194+
S32 LLWinSpellEngine::getSuggestions(const std::string& word, std::vector<std::string>& suggestions) const
195+
{
196+
llassert(on_main_thread());
197+
suggestions.clear();
198+
if (!mChecker)
199+
{
200+
return 0;
201+
}
202+
203+
const std::wstring wword = toWide(word);
204+
if (wword.empty())
205+
{
206+
return 0;
207+
}
208+
209+
IEnumString* enum_str = nullptr;
210+
const HRESULT hr = mChecker->Suggest(wword.c_str(), &enum_str);
211+
if (FAILED(hr) || (!enum_str))
212+
{
213+
return 0;
214+
}
215+
216+
// Suggest() returns S_FALSE when the word is already spelled correctly, in which case the
217+
// enumeration just echoes the input word - skip it so we don't offer the word as its own fix.
218+
if (S_OK == hr)
219+
{
220+
LPOLESTR psz = nullptr;
221+
ULONG fetched = 0;
222+
while ((enum_str->Next(1, &psz, &fetched) == S_OK) && (1 == fetched) && psz)
223+
{
224+
suggestions.push_back(fromWide(psz));
225+
CoTaskMemFree(psz);
226+
psz = nullptr;
227+
}
228+
}
229+
enum_str->Release();
230+
231+
return static_cast<S32>(suggestions.size());
232+
}
233+
234+
std::set<std::string> LLWinSpellEngine::getInstalledLanguages(const std::vector<std::string>& candidate_names) const
235+
{
236+
llassert(on_main_thread());
237+
std::set<std::string> installed;
238+
if (!mFactory)
239+
{
240+
return installed;
241+
}
242+
const std::vector<std::string> available = availableTags(); // one OS query for the batch
243+
for (const std::string& name : candidate_names)
244+
{
245+
if (!LLSpellCheckEngine::matchLanguage(name, available, '-').empty())
246+
{
247+
installed.insert(name);
248+
}
249+
}
250+
return installed;
251+
}
252+
}
253+
254+
// static
255+
std::unique_ptr<LLSpellCheckEngine> LLSpellCheckEngine::create()
256+
{
257+
return std::make_unique<LLWinSpellEngine>();
258+
}

indra/vcpkg.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
},
5151
"glm",
5252
"harfbuzz",
53-
"hunspell",
5453
{
5554
"name": "libavif",
5655
"features": [
@@ -197,6 +196,12 @@
197196
"faudio"
198197
]
199198
},
199+
"hunspell": {
200+
"description": "Build with support for Hunspell spell-check engine",
201+
"dependencies": [
202+
"hunspell"
203+
]
204+
},
200205
"kdu": {
201206
"description": "Build with support for Kakadu JPEG2000",
202207
"dependencies": [

0 commit comments

Comments
 (0)