diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b63035d..bd58a4e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -30,6 +30,7 @@ set(SOURCES katvan_previewerview.cpp katvan_recentfiles.cpp katvan_searchbar.cpp + katvan_spellchecker_hunspell.cpp katvan_spellchecker.cpp katvan_typstdriverwrapper.cpp katvan_utils.cpp diff --git a/src/katvan_editor.cpp b/src/katvan_editor.cpp index 925b06c..d596b9b 100644 --- a/src/katvan_editor.cpp +++ b/src/katvan_editor.cpp @@ -27,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -88,16 +87,14 @@ class LineNumberGutter : public QWidget Editor::Editor(QWidget* parent) : QTextEdit(parent) , d_theme(EditorTheme::defaultTheme()) - , d_inPaletteChange(false) , d_pendingSuggestionsPosition(-1) { setAcceptRichText(false); - d_spellChecker = new SpellChecker(this); - connect(d_spellChecker, &SpellChecker::suggestionsReady, this, &Editor::spellingSuggestionsReady); - connect(d_spellChecker, &SpellChecker::dictionaryChanged, this, &Editor::forceRehighlighting); + connect(SpellChecker::instance(), &SpellChecker::suggestionsReady, this, &Editor::spellingSuggestionsReady); + connect(SpellChecker::instance(), &SpellChecker::dictionaryChanged, this, &Editor::forceRehighlighting); - d_highlighter = new Highlighter(document(), d_spellChecker, d_theme); + d_highlighter = new Highlighter(document(), SpellChecker::instance(), d_theme); d_codeModel = new CodeModel(document(), this); d_leftLineNumberGutter = new LineNumberGutter(this); @@ -379,7 +376,7 @@ void Editor::contextMenuEvent(QContextMenuEvent* event) QAction* addToPersonalAction = new QAction(tr("Add to Personal Dictionary")); connect(addToPersonalAction, &QAction::triggered, this, [this, misspelledWord, cursor]() { - d_spellChecker->addToPersonalDictionary(misspelledWord); + SpellChecker::instance()->addToPersonalDictionary(misspelledWord); forceRehighlighting(); }); @@ -397,7 +394,7 @@ void Editor::contextMenuEvent(QContextMenuEvent* event) // signal will be instantly invoked as a direct connection. d_pendingSuggestionsWord = misspelledWord; d_pendingSuggestionsPosition = cursor.position(); - d_spellChecker->requestSuggestions(misspelledWord, cursor.position()); + SpellChecker::instance()->requestSuggestions(misspelledWord, cursor.position()); } d_contextMenu->popup(event->globalPos()); } @@ -690,7 +687,7 @@ void Editor::keyPressEvent(QKeyEvent* event) setTextBlockDirection(Qt::RightToLeft); return; } -#elif defined(Q_OS_MAC) +#elif defined(Q_OS_MACOS) if (event->keyCombination().keyboardModifiers() == (Qt::MetaModifier | Qt::ShiftModifier)) { if (event->nativeVirtualKey() == 0x38) { // kVK_Shift d_pendingDirectionChange = Qt::LeftToRight; diff --git a/src/katvan_editor.h b/src/katvan_editor.h index c1af3c4..dccc65f 100644 --- a/src/katvan_editor.h +++ b/src/katvan_editor.h @@ -33,7 +33,6 @@ QT_END_NAMESPACE namespace katvan { class Highlighter; -class SpellChecker; class CodeModel; class Editor : public QTextEdit @@ -45,8 +44,6 @@ class Editor : public QTextEdit public: Editor(QWidget* parent = nullptr); - SpellChecker* spellChecker() const { return d_spellChecker; } - void applySettings(const EditorSettings& settings); void updateEditorTheme(); @@ -108,7 +105,6 @@ private slots: QTimer* d_debounceTimer; Highlighter* d_highlighter; - SpellChecker* d_spellChecker; CodeModel* d_codeModel; EditorSettings d_appSettings; @@ -116,8 +112,6 @@ private slots: EditorSettings d_effectiveSettings; EditorTheme d_theme; - bool d_inPaletteChange; - QPointer d_contextMenu; std::optional d_pendingDirectionChange; QString d_pendingSuggestionsWord; diff --git a/src/katvan_mainwindow.cpp b/src/katvan_mainwindow.cpp index 9238555..a7f92c1 100644 --- a/src/katvan_mainwindow.cpp +++ b/src/katvan_mainwindow.cpp @@ -753,8 +753,9 @@ void MainWindow::restoreSpellingDictionary(const QSettings& settings) QString dictName = settings.value(SETTING_SPELLING_DICT, QString()).toString(); QString dictPath; + SpellChecker* checker = SpellChecker::instance(); if (!dictName.isEmpty()) { - QMap allDicts = SpellChecker::findDictionaries(); + QMap allDicts = checker->findDictionaries(); if (!allDicts.contains(dictName)) { dictName.clear(); } @@ -763,13 +764,14 @@ void MainWindow::restoreSpellingDictionary(const QSettings& settings) } } - d_editor->spellChecker()->setCurrentDictionary(dictName, dictPath); + checker->setCurrentDictionary(dictName, dictPath); d_spellingButton->setText(dictName.isEmpty() ? tr("None") : dictName); } void MainWindow::changeSpellCheckingDictionary() { - QMap dicts = SpellChecker::findDictionaries(); + SpellChecker* checker = SpellChecker::instance(); + QMap dicts = checker->findDictionaries(); QStringList dictNames = { "" }; QStringList dictLabels = { tr("None") }; @@ -778,10 +780,10 @@ void MainWindow::changeSpellCheckingDictionary() dictNames.append(*kit); dictLabels.append(QString("%1 - %2").arg( *kit, - SpellChecker::dictionaryDisplayName(*kit))); + checker->dictionaryDisplayName(*kit))); } - QString currentDict = d_editor->spellChecker()->currentDictionaryName(); + QString currentDict = checker->currentDictionaryName(); int index = dictNames.indexOf(currentDict); if (index < 0) { index = 0; @@ -801,7 +803,7 @@ void MainWindow::changeSpellCheckingDictionary() } QString selectedDictName = dictNames[dictLabels.indexOf(result)]; - d_editor->spellChecker()->setCurrentDictionary(selectedDictName, dicts.value(selectedDictName)); + checker->setCurrentDictionary(selectedDictName, dicts.value(selectedDictName)); d_spellingButton->setText(selectedDictName.isEmpty() ? result : selectedDictName); QSettings settings; diff --git a/src/katvan_spellchecker.cpp b/src/katvan_spellchecker.cpp index 838da1b..38250ed 100644 --- a/src/katvan_spellchecker.cpp +++ b/src/katvan_spellchecker.cpp @@ -15,107 +15,32 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#include "katvan_spellchecker_hunspell.h" #include "katvan_spellchecker.h" -#include - -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include namespace katvan { static constexpr size_t SUGGESTIONS_CACHE_SIZE = 25; -QString SpellChecker::s_personalDictionaryLocation; - -struct LoadedSpeller -{ - LoadedSpeller(const char* affPath, const char* dicPath) - : speller(affPath, dicPath) {} - - Hunspell speller; - QMutex mutex; -}; - SpellChecker::SpellChecker(QObject* parent) : QObject(parent) , d_suggestionsCache(SUGGESTIONS_CACHE_SIZE) { - d_workerThread = new QThread(this); - d_workerThread->setObjectName("SpellerWorkerThread"); - - QString loc = s_personalDictionaryLocation; - if (loc.isEmpty()) { - loc = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - } - - d_personalDictionaryPath = loc + QDir::separator() + "/personal.dic"; - loadPersonalDictionary(); - - d_watcher = new QFileSystemWatcher(this); - d_watcher->addPath(d_personalDictionaryPath); - connect(d_watcher, &QFileSystemWatcher::fileChanged, this, &SpellChecker::personalDictionaryFileChanged); } SpellChecker::~SpellChecker() { - if (d_workerThread->isRunning()) { - d_workerThread->quit(); - d_workerThread->wait(); - } } -void SpellChecker::ensureWorkerThread() +SpellChecker* SpellChecker::instance() { - if (!d_workerThread->isRunning()) { - d_workerThread->start(); + static SpellChecker* checker = nullptr; + if (!checker) { + checker = new HunspellSpellChecker(); } -} - -/** - * Scan system and executable-local locations for Hunspell dictionaries, - * which are a pair of *.aff and *.dic files with the same base name. - */ -QMap SpellChecker::findDictionaries() -{ - QStringList dictDirs; - dictDirs.append(QCoreApplication::applicationDirPath() + "/hunspell"); - - QStringList systemDirs = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation); - for (const QString& dir : std::as_const(systemDirs)) { - dictDirs.append(dir + "/hunspell"); - } - - QStringList nameFilters = { "*.aff" }; - QMap affFiles; - - for (const QString& dirName : dictDirs) { - QDir dir(dirName); - QFileInfoList affixFiles = dir.entryInfoList(nameFilters, QDir::Files); - - for (QFileInfo& affInfo : affixFiles) { - QString dictName = affInfo.baseName(); - QString dicFile = dirName + "/" + dictName + ".dic"; - if (!QFileInfo::exists(dicFile)) { - continue; - } - - if (!affFiles.contains(dictName)) { - affFiles.insert(dictName, affInfo.absoluteFilePath()); - } - } - } - return affFiles; + return checker; } QString SpellChecker::dictionaryDisplayName(const QString& dictName) @@ -133,208 +58,16 @@ QString SpellChecker::dictionaryDisplayName(const QString& dictName) void SpellChecker::setPersonalDictionaryLocation(const QString& dirPath) { - s_personalDictionaryLocation = dirPath; -} - -void SpellChecker::setCurrentDictionary(const QString& dictName, const QString& dictAffFile) -{ - d_suggestionsCache.clear(); - - if (dictName.isEmpty()) { - d_currentDictName = dictName; - Q_EMIT dictionaryChanged(dictName); - return; - } - - DictionaryLoaderWorker* worker = new DictionaryLoaderWorker(dictName, dictAffFile); - - ensureWorkerThread(); - worker->moveToThread(d_workerThread); - - connect(worker, &DictionaryLoaderWorker::dictionaryLoaded, this, &SpellChecker::loaderWorkerDone); - QMetaObject::invokeMethod(worker, &DictionaryLoaderWorker::process, Qt::QueuedConnection); -} - -static bool isSingleGrapheme(const QString& word) -{ - QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, word); - qsizetype pos = finder.toNextBoundary(); - return (pos >= 0 && finder.toNextBoundary() < 0); -} - -static bool isHebrewOrdinal(const QString& normalizedWord) -{ - if (normalizedWord.size() != 2) { - return false; - } - - QChar a = normalizedWord[0]; - QChar b = normalizedWord[1]; - - return a.category() == QChar::Letter_Other - && a.script() == QChar::Script_Hebrew - && (b == QLatin1Char('\'') || b == QChar(0x05F3)); -} - -static QChar::Script dominantScriptForWord(const QString& word) -{ - for (const QChar& ch : word) { - if (ch.script() > QChar::Script_Common) { - return ch.script(); - } - } - return QChar::Script_Unknown; -} - -static bool isAppropriateScriptForDictionary(QChar::Script dictionaryScript, const QString& word) -{ - if (dictionaryScript == QChar::Script_Unknown) { - return true; - } - - QChar::Script wordScript = dominantScriptForWord(word); - return wordScript == QChar::Script_Unknown || wordScript == dictionaryScript; -} - -static QChar::Script getDictionaryScript(const QString& dictName) -{ - QLocale locale(dictName); - if (locale.language() == QLocale::C) { - return QChar::Script_Unknown; - } - - switch (locale.script()) { - case QLocale::ArabicScript: return QChar::Script_Arabic; - case QLocale::CyrillicScript: return QChar::Script_Cyrillic; - case QLocale::HebrewScript: return QChar::Script_Hebrew; - case QLocale::LatinScript: return QChar::Script_Latin; - default: return QChar::Script_Unknown; - } -} - -bool SpellChecker::checkWord(Hunspell& speller, QChar::Script dictionaryScript, const QString& word) -{ - QString normalizedWord = word.normalized(QString::NormalizationForm_D); - if (d_personalDictionary.contains(normalizedWord)) { - return true; - } - - // Hunspell seems to emit a lot of false positives, so reduce them with - // some heuristics: - // - Ignore single Unicode grapheme words (single character, emoji, etc) - // - Ignore Hebrew ordinals (single Hebrew letter followed by a Geresh) - // - Ignore words written in script that doesn't fit the dictionary's locale. - if (isSingleGrapheme(word) - || isHebrewOrdinal(normalizedWord) - || !isAppropriateScriptForDictionary(dictionaryScript, word)) { - return true; - } - - return speller.spell(word.toStdString()); -} - -QList> SpellChecker::checkSpelling(const QString& text) -{ - QList> result; - if (d_currentDictName.isEmpty()) { - return result; - } - - LoadedSpeller* speller = d_spellers[d_currentDictName].get(); - if (!speller->mutex.tryLock()) { - // Do not block the UI event loop! If we can't take the speller - // lock (because suggestions are being generated at the moment), - // just pretend there are no spelling mistakes here. - return result; - } - - QChar::Script dictScript = getDictionaryScript(d_currentDictName); - - QTextBoundaryFinder boundryFinder(QTextBoundaryFinder::Word, text); - - qsizetype prevPos = 0; - while (boundryFinder.toNextBoundary() >= 0) { - qsizetype pos = boundryFinder.position(); - if (boundryFinder.boundaryReasons() & QTextBoundaryFinder::EndOfItem) { - QString word = text.sliced(prevPos, pos - prevPos); - bool ok = checkWord(speller->speller, dictScript, word); - if (!ok) { - result.append(std::make_pair(prevPos, pos - prevPos)); - } - } - prevPos = pos; - } - - speller->mutex.unlock(); - return result; -} - -void SpellChecker::addToPersonalDictionary(const QString& word) -{ - d_personalDictionary.insert(word.normalized(QString::NormalizationForm_D)); - flushPersonalDictionary(); + Q_UNUSED(dirPath) } -void SpellChecker::flushPersonalDictionary() +void SpellChecker::setCurrentDictionary(const QString& dictName, const QString& dictPath) { - QDir dictDir = QFileInfo(d_personalDictionaryPath).dir(); - if (!dictDir.exists()) { - dictDir.mkpath("."); - } - - QSaveFile file(d_personalDictionaryPath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - QMessageBox::critical( - QApplication::activeWindow(), - QCoreApplication::applicationName(), - tr("Saving personal dictionary to %1 failed: %2").arg(d_personalDictionaryPath, file.errorString())); - - return; - } - - QTextStream stream(&file); - for (const QString& word : std::as_const(d_personalDictionary)) { - stream << word << "\n"; - } - file.commit(); -} - -void SpellChecker::loadPersonalDictionary() -{ - if (!QFileInfo::exists(d_personalDictionaryPath)) { - return; - } - - QFile file(d_personalDictionaryPath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - QMessageBox::critical( - QApplication::activeWindow(), - QCoreApplication::applicationName(), - tr("Loading personal dictionary from %1 failed: %2").arg(d_personalDictionaryPath, file.errorString())); - - return; - } - - d_personalDictionary.clear(); - - QString line; - QTextStream stream(&file); - while (stream.readLineInto(&line)) { - if (line.isEmpty()) { - continue; - } - d_personalDictionary.insert(line.normalized(QString::NormalizationForm_D)); - } -} - -void SpellChecker::personalDictionaryFileChanged() -{ - qDebug() << "Personal dictionary file changed on disk"; - loadPersonalDictionary(); + Q_UNUSED(dictPath) + d_currentDictName = dictName; - if (!d_watcher->files().contains(d_personalDictionaryPath)) { - d_watcher->addPath(d_personalDictionaryPath); - } + d_suggestionsCache.clear(); + Q_EMIT dictionaryChanged(dictName); } void SpellChecker::requestSuggestions(const QString& word, int position) @@ -349,64 +82,13 @@ void SpellChecker::requestSuggestions(const QString& word, int position) return; } - LoadedSpeller* speller = d_spellers[d_currentDictName].get(); - SpellingSuggestionsWorker* worker = new SpellingSuggestionsWorker(speller, word, position); - - ensureWorkerThread(); - worker->moveToThread(d_workerThread); - - connect(worker, &SpellingSuggestionsWorker::suggestionsReady, this, &SpellChecker::suggestionsWorkerDone); - QMetaObject::invokeMethod(worker, &SpellingSuggestionsWorker::process, Qt::QueuedConnection); -} - -void SpellChecker::loaderWorkerDone(QString dictName, katvan::LoadedSpeller* speller) -{ - std::unique_ptr spellerPtr(speller); - - if (!d_spellers.contains(dictName)) { - d_spellers.emplace(dictName, std::move(spellerPtr)); - } - - d_currentDictName = dictName; - Q_EMIT dictionaryChanged(dictName); + requestSuggestionsImpl(word, position); } -void SpellChecker::suggestionsWorkerDone(QString word, int position, QStringList suggestions) +void SpellChecker::suggestionsCalculated(const QString& word, int position, const QStringList& suggestions) { d_suggestionsCache.insert(word, new QStringList(suggestions)); - Q_EMIT suggestionsReady(word, position, suggestions); } -void DictionaryLoaderWorker::process() -{ - QString dicFile = QFileInfo(d_dictAffFile).path() + "/" + d_dictName + ".dic"; - - QByteArray affPath = d_dictAffFile.toLocal8Bit(); - QByteArray dicPath = dicFile.toLocal8Bit(); - - Q_EMIT dictionaryLoaded(d_dictName, new LoadedSpeller(affPath.data(), dicPath.data())); - - deleteLater(); -} - -void SpellingSuggestionsWorker::process() -{ - std::vector suggestions; - { - QMutexLocker locker{ &d_speller->mutex }; - suggestions = d_speller->speller.suggest(d_word.toStdString()); - } - - QStringList result; - result.reserve(suggestions.size()); - for (const auto& s : suggestions) { - result.append(QString::fromStdString(s)); - } - - Q_EMIT suggestionsReady(d_word, d_pos, result); - - deleteLater(); -} - } diff --git a/src/katvan_spellchecker.h b/src/katvan_spellchecker.h index 9e501ff..a065e36 100644 --- a/src/katvan_spellchecker.h +++ b/src/katvan_spellchecker.h @@ -21,23 +21,12 @@ #include #include #include -#include #include -#include -#include - -QT_BEGIN_NAMESPACE -class QFileSystemWatcher; -class QThread; -QT_END_NAMESPACE - -class Hunspell; +#include namespace katvan { -struct LoadedSpeller; - class SpellChecker : public QObject { Q_OBJECT @@ -46,16 +35,18 @@ class SpellChecker : public QObject SpellChecker(QObject* parent = nullptr); ~SpellChecker(); - static QMap findDictionaries(); - static QString dictionaryDisplayName(const QString& dictName); - static void setPersonalDictionaryLocation(const QString& dirPath); + static SpellChecker* instance(); - QString currentDictionaryName() const { return d_currentDictName; } - void setCurrentDictionary(const QString& dictName, const QString& dictAffFile); + virtual QMap findDictionaries() = 0; + virtual QString dictionaryDisplayName(const QString& dictName); + virtual void setPersonalDictionaryLocation(const QString& dirPath); - QList> checkSpelling(const QString& text); + virtual QString currentDictionaryName() const { return d_currentDictName; } + virtual void setCurrentDictionary(const QString& dictName, const QString& dictPath); - void addToPersonalDictionary(const QString& word); + using MisspelledWordRanges = QList>; + virtual MisspelledWordRanges checkSpelling(const QString& text) = 0; + virtual void addToPersonalDictionary(const QString& word) = 0; void requestSuggestions(const QString& word, int position); @@ -63,71 +54,15 @@ class SpellChecker : public QObject void dictionaryChanged(const QString& dictName); void suggestionsReady(const QString& word, int position, const QStringList& suggestions); -private slots: - void personalDictionaryFileChanged(); - void loaderWorkerDone(QString dictName, katvan::LoadedSpeller* speller); - void suggestionsWorkerDone(QString word, int position, QStringList suggestions); - -private: - void ensureWorkerThread(); - bool checkWord(Hunspell& speller, QChar::Script dictionaryScript, const QString& word); - void flushPersonalDictionary(); - void loadPersonalDictionary(); +protected slots: + void suggestionsCalculated(const QString& word, int position, const QStringList& suggestions); - static QString s_personalDictionaryLocation; +protected: + virtual void requestSuggestionsImpl(const QString& word, int position) = 0; +private: QString d_currentDictName; QCache d_suggestionsCache; - - QString d_personalDictionaryPath; - QSet d_personalDictionary; - - QFileSystemWatcher* d_watcher; - QThread* d_workerThread; - - std::map> d_spellers; -}; - -class DictionaryLoaderWorker : public QObject -{ - Q_OBJECT - -public: - DictionaryLoaderWorker(const QString& dictName, const QString& dictAffFile) - : d_dictName(dictName) - , d_dictAffFile(dictAffFile) {} - -public slots: - void process(); - -signals: - void dictionaryLoaded(QString dictName, katvan::LoadedSpeller* speller); - -private: - QString d_dictName; - QString d_dictAffFile; -}; - -class SpellingSuggestionsWorker : public QObject -{ - Q_OBJECT - -public: - SpellingSuggestionsWorker(LoadedSpeller* speller, const QString& word, int position) - : d_speller(speller) - , d_word(word) - , d_pos(position) {} - -public slots: - void process(); - -signals: - void suggestionsReady(QString word, int position, QStringList suggestions); - -private: - LoadedSpeller* d_speller; - QString d_word; - int d_pos; }; } diff --git a/src/katvan_spellchecker_hunspell.cpp b/src/katvan_spellchecker_hunspell.cpp new file mode 100644 index 0000000..25c108a --- /dev/null +++ b/src/katvan_spellchecker_hunspell.cpp @@ -0,0 +1,374 @@ +/* + * This file is part of Katvan + * Copyright (c) 2024 Igor Khanin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "katvan_spellchecker_hunspell.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace katvan { + +QString HunspellSpellChecker::s_personalDictionaryLocation; + +struct LoadedSpeller +{ + LoadedSpeller(const char* affPath, const char* dicPath) + : speller(affPath, dicPath) {} + + Hunspell speller; + QMutex mutex; +}; + +HunspellSpellChecker::HunspellSpellChecker(QObject* parent) + : SpellChecker(parent) +{ + d_workerThread = new QThread(this); + d_workerThread->setObjectName("HunspellWorkerThread"); + + QString loc = s_personalDictionaryLocation; + if (loc.isEmpty()) { + loc = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + } + + d_personalDictionaryPath = loc + QDir::separator() + "/personal.dic"; + loadPersonalDictionary(); + + d_watcher = new QFileSystemWatcher(this); + d_watcher->addPath(d_personalDictionaryPath); + connect(d_watcher, &QFileSystemWatcher::fileChanged, this, &HunspellSpellChecker::personalDictionaryFileChanged); +} + +HunspellSpellChecker::~HunspellSpellChecker() +{ + if (d_workerThread->isRunning()) { + d_workerThread->quit(); + d_workerThread->wait(); + } +} + +void HunspellSpellChecker::ensureWorkerThread() +{ + if (!d_workerThread->isRunning()) { + d_workerThread->start(); + } +} + +/** + * Scan system and executable-local locations for Hunspell dictionaries, + * which are a pair of *.aff and *.dic files with the same base name. + */ +QMap HunspellSpellChecker::findDictionaries() +{ + QStringList dictDirs; + dictDirs.append(QCoreApplication::applicationDirPath() + "/hunspell"); + + QStringList systemDirs = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation); + for (const QString& dir : std::as_const(systemDirs)) { + dictDirs.append(dir + "/hunspell"); + } + + QStringList nameFilters = { "*.aff" }; + QMap affFiles; + + for (const QString& dirName : dictDirs) { + QDir dir(dirName); + QFileInfoList affixFiles = dir.entryInfoList(nameFilters, QDir::Files); + + for (QFileInfo& affInfo : affixFiles) { + QString dictName = affInfo.baseName(); + QString dicFile = dirName + "/" + dictName + ".dic"; + if (!QFileInfo::exists(dicFile)) { + continue; + } + + if (!affFiles.contains(dictName)) { + affFiles.insert(dictName, affInfo.absoluteFilePath()); + } + } + } + return affFiles; +} + +void HunspellSpellChecker::setPersonalDictionaryLocation(const QString& dirPath) +{ + s_personalDictionaryLocation = dirPath; +} + +void HunspellSpellChecker::setCurrentDictionary(const QString& dictName, const QString& dictAffFile) +{ + if (dictName.isEmpty()) { + SpellChecker::setCurrentDictionary(dictName, dictAffFile); + return; + } + + DictionaryLoaderWorker* worker = new DictionaryLoaderWorker(dictName, dictAffFile); + + ensureWorkerThread(); + worker->moveToThread(d_workerThread); + + connect(worker, &DictionaryLoaderWorker::dictionaryLoaded, this, &HunspellSpellChecker::loaderWorkerDone); + QMetaObject::invokeMethod(worker, &DictionaryLoaderWorker::process, Qt::QueuedConnection); +} + +static bool isSingleGrapheme(const QString& word) +{ + QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, word); + qsizetype pos = finder.toNextBoundary(); + return (pos >= 0 && finder.toNextBoundary() < 0); +} + +static bool isHebrewOrdinal(const QString& normalizedWord) +{ + if (normalizedWord.size() != 2) { + return false; + } + + QChar a = normalizedWord[0]; + QChar b = normalizedWord[1]; + + return a.category() == QChar::Letter_Other + && a.script() == QChar::Script_Hebrew + && (b == QLatin1Char('\'') || b == QChar(0x05F3)); +} + +static QChar::Script dominantScriptForWord(const QString& word) +{ + for (const QChar& ch : word) { + if (ch.script() > QChar::Script_Common) { + return ch.script(); + } + } + return QChar::Script_Unknown; +} + +static bool isAppropriateScriptForDictionary(QChar::Script dictionaryScript, const QString& word) +{ + if (dictionaryScript == QChar::Script_Unknown) { + return true; + } + + QChar::Script wordScript = dominantScriptForWord(word); + return wordScript == QChar::Script_Unknown || wordScript == dictionaryScript; +} + +static QChar::Script getDictionaryScript(const QString& dictName) +{ + QLocale locale(dictName); + if (locale.language() == QLocale::C) { + return QChar::Script_Unknown; + } + + switch (locale.script()) { + case QLocale::ArabicScript: return QChar::Script_Arabic; + case QLocale::CyrillicScript: return QChar::Script_Cyrillic; + case QLocale::HebrewScript: return QChar::Script_Hebrew; + case QLocale::LatinScript: return QChar::Script_Latin; + default: return QChar::Script_Unknown; + } +} + +bool HunspellSpellChecker::checkWord(Hunspell& speller, QChar::Script dictionaryScript, const QString& word) +{ + QString normalizedWord = word.normalized(QString::NormalizationForm_D); + if (d_personalDictionary.contains(normalizedWord)) { + return true; + } + + // Hunspell seems to emit a lot of false positives, so reduce them with + // some heuristics: + // - Ignore single Unicode grapheme words (single character, emoji, etc) + // - Ignore Hebrew ordinals (single Hebrew letter followed by a Geresh) + // - Ignore words written in script that doesn't fit the dictionary's locale. + if (isSingleGrapheme(word) + || isHebrewOrdinal(normalizedWord) + || !isAppropriateScriptForDictionary(dictionaryScript, word)) { + return true; + } + + return speller.spell(word.toStdString()); +} + +SpellChecker::MisspelledWordRanges HunspellSpellChecker::checkSpelling(const QString& text) +{ + MisspelledWordRanges result; + if (currentDictionaryName().isEmpty()) { + return result; + } + + LoadedSpeller* speller = d_spellers[currentDictionaryName()].get(); + if (!speller->mutex.tryLock()) { + // Do not block the UI event loop! If we can't take the speller + // lock (because suggestions are being generated at the moment), + // just pretend there are no spelling mistakes here. + return result; + } + + QChar::Script dictScript = getDictionaryScript(currentDictionaryName()); + + QTextBoundaryFinder boundryFinder(QTextBoundaryFinder::Word, text); + + qsizetype prevPos = 0; + while (boundryFinder.toNextBoundary() >= 0) { + qsizetype pos = boundryFinder.position(); + if (boundryFinder.boundaryReasons() & QTextBoundaryFinder::EndOfItem) { + QString word = text.sliced(prevPos, pos - prevPos); + bool ok = checkWord(speller->speller, dictScript, word); + if (!ok) { + result.append(std::make_pair(prevPos, pos - prevPos)); + } + } + prevPos = pos; + } + + speller->mutex.unlock(); + return result; +} + +void HunspellSpellChecker::addToPersonalDictionary(const QString& word) +{ + d_personalDictionary.insert(word.normalized(QString::NormalizationForm_D)); + flushPersonalDictionary(); +} + +void HunspellSpellChecker::flushPersonalDictionary() +{ + QDir dictDir = QFileInfo(d_personalDictionaryPath).dir(); + if (!dictDir.exists()) { + dictDir.mkpath("."); + } + + QSaveFile file(d_personalDictionaryPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::critical( + QApplication::activeWindow(), + QCoreApplication::applicationName(), + tr("Saving personal dictionary to %1 failed: %2").arg(d_personalDictionaryPath, file.errorString())); + + return; + } + + QTextStream stream(&file); + for (const QString& word : std::as_const(d_personalDictionary)) { + stream << word << "\n"; + } + file.commit(); +} + +void HunspellSpellChecker::loadPersonalDictionary() +{ + if (!QFileInfo::exists(d_personalDictionaryPath)) { + return; + } + + QFile file(d_personalDictionaryPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::critical( + QApplication::activeWindow(), + QCoreApplication::applicationName(), + tr("Loading personal dictionary from %1 failed: %2").arg(d_personalDictionaryPath, file.errorString())); + + return; + } + + d_personalDictionary.clear(); + + QString line; + QTextStream stream(&file); + while (stream.readLineInto(&line)) { + if (line.isEmpty()) { + continue; + } + d_personalDictionary.insert(line.normalized(QString::NormalizationForm_D)); + } +} + +void HunspellSpellChecker::personalDictionaryFileChanged() +{ + qDebug() << "Personal dictionary file changed on disk"; + loadPersonalDictionary(); + + if (!d_watcher->files().contains(d_personalDictionaryPath)) { + d_watcher->addPath(d_personalDictionaryPath); + } +} + +void HunspellSpellChecker::requestSuggestionsImpl(const QString& word, int position) +{ + LoadedSpeller* speller = d_spellers[currentDictionaryName()].get(); + SpellingSuggestionsWorker* worker = new SpellingSuggestionsWorker(speller, word, position); + + ensureWorkerThread(); + worker->moveToThread(d_workerThread); + + connect(worker, &SpellingSuggestionsWorker::suggestionsReady, this, &HunspellSpellChecker::suggestionsCalculated); + QMetaObject::invokeMethod(worker, &SpellingSuggestionsWorker::process, Qt::QueuedConnection); +} + +void HunspellSpellChecker::loaderWorkerDone(QString dictName, katvan::LoadedSpeller* speller) +{ + std::unique_ptr spellerPtr(speller); + + if (!d_spellers.contains(dictName)) { + d_spellers.emplace(dictName, std::move(spellerPtr)); + } + + SpellChecker::setCurrentDictionary(dictName, QString()); +} + +void DictionaryLoaderWorker::process() +{ + QString dicFile = QFileInfo(d_dictAffFile).path() + "/" + d_dictName + ".dic"; + + QByteArray affPath = d_dictAffFile.toLocal8Bit(); + QByteArray dicPath = dicFile.toLocal8Bit(); + + Q_EMIT dictionaryLoaded(d_dictName, new LoadedSpeller(affPath.data(), dicPath.data())); + + deleteLater(); +} + +void SpellingSuggestionsWorker::process() +{ + std::vector suggestions; + { + QMutexLocker locker{ &d_speller->mutex }; + suggestions = d_speller->speller.suggest(d_word.toStdString()); + } + + QStringList result; + result.reserve(suggestions.size()); + for (const auto& s : suggestions) { + result.append(QString::fromStdString(s)); + } + + Q_EMIT suggestionsReady(d_word, d_pos, result); + + deleteLater(); +} + +} diff --git a/src/katvan_spellchecker_hunspell.h b/src/katvan_spellchecker_hunspell.h new file mode 100644 index 0000000..01c0c47 --- /dev/null +++ b/src/katvan_spellchecker_hunspell.h @@ -0,0 +1,120 @@ +/* + * This file is part of Katvan + * Copyright (c) 2024 Igor Khanin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "katvan_spellchecker.h" + +#include + +#include +#include + +QT_BEGIN_NAMESPACE +class QFileSystemWatcher; +class QThread; +QT_END_NAMESPACE + +class Hunspell; + +namespace katvan { + +struct LoadedSpeller; + +class HunspellSpellChecker : public SpellChecker +{ + Q_OBJECT + +public: + HunspellSpellChecker(QObject* parent = nullptr); + ~HunspellSpellChecker(); + + QMap findDictionaries() override; + void setPersonalDictionaryLocation(const QString& dirPath) override; + + void setCurrentDictionary(const QString& dictName, const QString& dictAffFile); + + MisspelledWordRanges checkSpelling(const QString& text) override; + + void addToPersonalDictionary(const QString& word) override; + +private slots: + void personalDictionaryFileChanged(); + void loaderWorkerDone(QString dictName, katvan::LoadedSpeller* speller); + +private: + void ensureWorkerThread(); + bool checkWord(Hunspell& speller, QChar::Script dictionaryScript, const QString& word); + void flushPersonalDictionary(); + void loadPersonalDictionary(); + + void requestSuggestionsImpl(const QString& word, int position) override; + + static QString s_personalDictionaryLocation; + + QString d_personalDictionaryPath; + QSet d_personalDictionary; + + QFileSystemWatcher* d_watcher; + QThread* d_workerThread; + + std::map> d_spellers; +}; + +class DictionaryLoaderWorker : public QObject +{ + Q_OBJECT + +public: + DictionaryLoaderWorker(const QString& dictName, const QString& dictAffFile) + : d_dictName(dictName) + , d_dictAffFile(dictAffFile) {} + +public slots: + void process(); + +signals: + void dictionaryLoaded(QString dictName, katvan::LoadedSpeller* speller); + +private: + QString d_dictName; + QString d_dictAffFile; +}; + +class SpellingSuggestionsWorker : public QObject +{ + Q_OBJECT + +public: + SpellingSuggestionsWorker(LoadedSpeller* speller, const QString& word, int position) + : d_speller(speller) + , d_word(word) + , d_pos(position) {} + +public slots: + void process(); + +signals: + void suggestionsReady(QString word, int position, QStringList suggestions); + +private: + LoadedSpeller* d_speller; + QString d_word; + int d_pos; +}; + +} diff --git a/src/katvan_utils.cpp b/src/katvan_utils.cpp index 08bb793..1622ec9 100644 --- a/src/katvan_utils.cpp +++ b/src/katvan_utils.cpp @@ -17,7 +17,7 @@ */ #include "katvan_utils.h" -#if defined(Q_OS_MAC) +#if defined(Q_OS_MACOS) #include "katvan_utils_macos.h" #endif @@ -62,7 +62,7 @@ Qt::LayoutDirection naturalTextDirection(const QString& text) QString showPdfExportDialog(QWidget* parent, const QString& sourceFilePath) { -#if defined(Q_OS_MAC) +#if defined(Q_OS_MACOS) return macos::showPdfExportDialog(parent, sourceFilePath); #else QFileDialog dialog(parent, QObject::tr("Export to PDF")); diff --git a/src/main.cpp b/src/main.cpp index 231b893..a52d61c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,7 +35,7 @@ void setupPortableMode() QSettings::setDefaultFormat(QSettings::IniFormat); QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, settingsPath); - katvan::SpellChecker::setPersonalDictionaryLocation(settingsPath + "/Katvan"); + katvan::SpellChecker::instance()->setPersonalDictionaryLocation(settingsPath + "/Katvan"); katvan::typstdriver::PackageManager::setDownloadCacheLocation(settingsPath + "/Katvan/cache"); } diff --git a/tests/katvan_spellchecker.t.cpp b/tests/katvan_spellchecker.t.cpp index 08e3eea..7d23a28 100644 --- a/tests/katvan_spellchecker.t.cpp +++ b/tests/katvan_spellchecker.t.cpp @@ -17,7 +17,7 @@ */ #include "katvan_testutils.h" -#include "katvan_spellchecker.h" +#include "katvan_spellchecker_hunspell.h" #include #include @@ -40,7 +40,8 @@ static QString getDictionaryPath(const char* name) } TEST(SpellCheckerTests, DetectDictionaries) { - QMap result = SpellChecker::findDictionaries(); + HunspellSpellChecker checker; + QMap result = checker.findDictionaries(); EXPECT_THAT(result.keys(), ::testing::IsSupersetOf({ QStringLiteral("en_IL"), QStringLiteral("he_XX"), @@ -51,7 +52,7 @@ TEST(SpellCheckerTests, DetectDictionaries) { } TEST(SpellCheckerTests, BasicEnglish) { - SpellChecker checker; + HunspellSpellChecker checker; QSignalSpy spy(&checker, &SpellChecker::dictionaryChanged); checker.setCurrentDictionary("en_IL", getDictionaryPath("en_IL")); @@ -69,7 +70,7 @@ TEST(SpellCheckerTests, BasicEnglish) { } TEST(SpellCheckerTests, BasicHebrew) { - SpellChecker checker; + HunspellSpellChecker checker; QSignalSpy spy(&checker, &SpellChecker::dictionaryChanged); checker.setCurrentDictionary("he_XX", getDictionaryPath("he_XX")); @@ -86,10 +87,10 @@ TEST(SpellCheckerTests, BasicHebrew) { TEST(SpellCheckerTests, PersonalDict) { QTemporaryDir dir; - SpellChecker::setPersonalDictionaryLocation(dir.path()); - SpellChecker checker1; + HunspellSpellChecker checker1; QSignalSpy spy1(&checker1, &SpellChecker::dictionaryChanged); + checker1.setPersonalDictionaryLocation(dir.path()); checker1.setCurrentDictionary("en_IL", getDictionaryPath("en_IL")); EXPECT_TRUE(spy1.wait(SIGNAL_WAIT_TIMEOUT_MSEC)); @@ -107,7 +108,7 @@ TEST(SpellCheckerTests, PersonalDict) { std::make_pair(5, 3) // bar )); - SpellChecker checker2; + HunspellSpellChecker checker2; QSignalSpy spy2(&checker2, &SpellChecker::dictionaryChanged); checker2.setCurrentDictionary("en_IL", getDictionaryPath("en_IL"));