From 19d54cb0aaafb92be5fcbaa6d27e0771a10ea903 Mon Sep 17 00:00:00 2001 From: Christophe Deschamps Date: Mon, 6 Nov 2023 12:57:38 +0000 Subject: [PATCH] Linux spell checker based on ispell --- .gitmodules | 5 +- CMakeLists.txt | 10 + external/ispell | 1 + linphone-app/CMakeLists.txt | 18 +- linphone-app/cmake/FindISpell.cmake | 47 +++++ .../linphone_package/CMakeLists.txt | 2 + linphone-app/src/app/paths/Paths.cpp | 8 + linphone-app/src/app/paths/Paths.hpp | 4 +- .../components/other/clipboard/Clipboard.cpp | 2 +- .../other/spell-checker/SpellChecker.cpp | 17 +- .../other/spell-checker/SpellChecker.hpp | 25 +++ .../other/spell-checker/SpellCheckerLinux.cpp | 178 +++++++++++++++++- linphone-app/src/utils/Constants.cpp | 2 + linphone-app/src/utils/Constants.hpp | 2 + .../modules/Common/Form/DroppableTextArea.qml | 4 +- 15 files changed, 316 insertions(+), 9 deletions(-) create mode 160000 external/ispell create mode 100644 linphone-app/cmake/FindISpell.cmake diff --git a/.gitmodules b/.gitmodules index 652ee1eaa..c39c0dad3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,4 +6,7 @@ path = linphone-sdk url = https://gitlab.linphone.org/BC/public/linphone-desktop-plugins/contacts/contacts-api.git [submodule "external/qtkeychain"] path = external/qtkeychain - url = https://gitlab.linphone.org/BC/public/external/qtkeychain.git \ No newline at end of file + url = https://gitlab.linphone.org/BC/public/external/qtkeychain.git +[submodule "external/ispell"] + path = external/ispell + url = https://gitlab.linphone.org/BC/public/external/ispell.git diff --git a/CMakeLists.txt b/CMakeLists.txt index b7560f350..d41da3cae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,9 @@ if( NOT QTKEYCHAIN_OUTPUT_DIR) # set this variable only if you don't build the m set(QTKEYCHAIN_OUTPUT_DIR "${CMAKE_INSTALL_PREFIX}")# Cannot be different from the current CMAKE_INSTALL_PREFIX endif() +if(NOT ISPELL_OUTPUT_DIR) # set this variable only if you don't build the module + set(ISPELL_OUTPUT_DIR "${CMAKE_INSTALL_PREFIX}")# Cannot be different from the current CMAKE_INSTALL_PREFIX +endif() # Avoid cmake warning if CMP0071 is not set. @@ -219,6 +222,13 @@ if(NOT APPLE OR MONO_ARCH) endfunction() add_linphone_keychain() endif() + if(NOT APPLE AND NOT WIN32) + function(add_linphone_ispell) + add_subdirectory("external/ispell") + endfunction() + add_linphone_ispell() + SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread") + endif() endif() function(add_linphone_app) set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) # Prevent project from overriding the options we just set here diff --git a/external/ispell b/external/ispell new file mode 160000 index 000000000..061c7e52b --- /dev/null +++ b/external/ispell @@ -0,0 +1 @@ +Subproject commit 061c7e52b507f146396c3b08f289c88ca598fc2f diff --git a/linphone-app/CMakeLists.txt b/linphone-app/CMakeLists.txt index 4a5286389..d3e0e7b69 100644 --- a/linphone-app/CMakeLists.txt +++ b/linphone-app/CMakeLists.txt @@ -67,7 +67,7 @@ list(APPEND CMAKE_MODULE_PATH "${LINPHONE_OUTPUT_DIR}/lib64/cmake") list(APPEND CMAKE_MODULE_PATH "${LINPHONE_OUTPUT_DIR}/lib/cmake") list(APPEND CMAKE_PREFIX_PATH "${QTKEYCHAIN_OUTPUT_DIR}/lib/cmake") - +list(APPEND CMAKE_PREFIX_PATH "${ISPELL_OUTPUT_DIR}/lib/cmake") if(APPLE) list(APPEND CMAKE_FRAMEWORK_PATH "${LINPHONE_OUTPUT_DIR}/Frameworks") @@ -132,6 +132,16 @@ if(ENABLE_QT_KEYCHAIN) endif() endif() +if(NOT APPLE AND NOT WIN32) + if(NOT ISPELL_TARGET_NAME) + set(ISPELL_TARGET_NAME "ISpell") + endif() + find_package(${ISPELL_TARGET_NAME}) + if(NOT ISpell_FOUND) + find_package(${ISPELL_TARGET_NAME} CONFIG REQUIRED) + endif() +endif() + if(ENABLE_BUILD_VERBOSE) message("INSTALL_PREFIX=${CMAKE_INSTALL_PREFIX} FRAMEWORK_PATH=${CMAKE_FRAMEWORK_PATH}, PREFIX_PATH=${CMAKE_PREFIX_PATH}") message("LINPHONE : ${LINPHONE_INCLUDE_DIRS} => ${LINPHONE_LIBRARIES}") @@ -194,6 +204,9 @@ if( ENABLE_QT_KEYCHAIN) endif() list(APPEND APP_TARGETS ${QTKEYCHAIN_TARGET_NAME}) endif() +if(NOT APPLE AND NOT WIN32) + list(APPEND APP_TARGETS ${ISPELL_TARGET_NAME}) +endif() if (UNIX AND NOT APPLE) list(APPEND QT5_PACKAGES DBus) endif () @@ -715,6 +728,9 @@ include_directories("${LINPHONE_OUTPUT_DIR}/include/OpenGL") include_directories("${LINPHONE_OUTPUT_DIR}/include/") include_directories("${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_INCLUDEDIR}/") include_directories("${QTKEYCHAIN_OUTPUT_DIR}/include/") +if(NOT APPLE AND NOT WIN32) + include_directories("${ISpell_BINARY_DIR}/include") +endif () if (CMAKE_INSTALL_RPATH) #Retrieve lib path from a know QT executable diff --git a/linphone-app/cmake/FindISpell.cmake b/linphone-app/cmake/FindISpell.cmake new file mode 100644 index 000000000..7a9269faf --- /dev/null +++ b/linphone-app/cmake/FindISpell.cmake @@ -0,0 +1,47 @@ +############################################################################ +# FindISpell.cmake +# Copyright (C) 2023 Belledonne Communications, Grenoble France +# +############################################################################ +# +# 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 2 +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +############################################################################ +# +# - Find the ispell include files and library +# +# ISpell_FOUND - system has lib ispell +# ISpell_SOURCE_DIR - the ispell include directory +# ISpell_BINARY_DIR - the ispell library directory + +if(NOT TARGET ${ISPELL_TARGET_NAME}) + set(EXPORT_PATH ${ISPELL_OUTPUT_DIR}) + include(GNUInstallDirs) + include(${EXPORT_PATH}/${CMAKE_INSTALL_LIBDIR}/cmake/${ISPELL_TARGET_NAME}/${ISPELL_TARGET_NAME}Config.cmake) +endif() + +set(_ISpell_REQUIRED_VARS ISpell_TARGET) +set(_ISpell_CACHE_VARS ${_ISpell_REQUIRED_VARS}) + +if(TARGET ${ISPELL_TARGET_NAME}) + set(ISpell_TARGET ${ISPELL_TARGET_NAME}) +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(ISpell + REQUIRED_VARS ${_ISpell_REQUIRED_VARS} + HANDLE_COMPONENTS +) +mark_as_advanced(${_ISpell_CACHE_VARS}) diff --git a/linphone-app/cmake_builder/linphone_package/CMakeLists.txt b/linphone-app/cmake_builder/linphone_package/CMakeLists.txt index 41d691c0d..b4d65909c 100644 --- a/linphone-app/cmake_builder/linphone_package/CMakeLists.txt +++ b/linphone-app/cmake_builder/linphone_package/CMakeLists.txt @@ -332,6 +332,8 @@ else()# Not Windows and Apple if(ENABLE_APP_WEBVIEW) install(FILES "${QT_PATH}/plugins/webview/libqtwebview_webengine.so" DESTINATION "plugins/webview") #Workaround : linuxdeploy doesn't deploy it endif() +# ISPELL + install(DIRECTORY "${ISpell_SOURCE_DIR}/ispell_dictionaries" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/${EXECUTABLE_NAME}" USE_SOURCE_PERMISSIONS) endif () diff --git a/linphone-app/src/app/paths/Paths.cpp b/linphone-app/src/app/paths/Paths.cpp index 538e20954..d735977bb 100644 --- a/linphone-app/src/app/paths/Paths.cpp +++ b/linphone-app/src/app/paths/Paths.cpp @@ -309,6 +309,14 @@ string Paths::getZrtpSecretsFilePath () { return getWritableFilePath(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + Constants::PathZrtpSecrets); } +QString Paths::getISpellDictsDirPath () { + return getAppPackageDataDirPath() + Constants::PathISpellDicts; +} + +string Paths::getISpellOwnDictsDirPath () { + return getWritableFilePath(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + Constants::PathISpellOwnDict; +} + // ----------------------------------------------------------------------------- static void migrateFile (const QString &oldPath, const QString &newPath) { diff --git a/linphone-app/src/app/paths/Paths.hpp b/linphone-app/src/app/paths/Paths.hpp index ec675de6a..8b62a6a71 100644 --- a/linphone-app/src/app/paths/Paths.hpp +++ b/linphone-app/src/app/paths/Paths.hpp @@ -55,7 +55,9 @@ namespace Paths { std::string getUserCertificatesDirPath (); std::string getZrtpDataFilePath (); std::string getZrtpSecretsFilePath (); - + QString getISpellDictsDirPath (); + std::string getISpellOwnDictsDirPath (); + void migrate (); } diff --git a/linphone-app/src/components/other/clipboard/Clipboard.cpp b/linphone-app/src/components/other/clipboard/Clipboard.cpp index 2d6b9cf05..ee5cc6223 100644 --- a/linphone-app/src/components/other/clipboard/Clipboard.cpp +++ b/linphone-app/src/components/other/clipboard/Clipboard.cpp @@ -56,7 +56,7 @@ QString Clipboard::getChatFormattedText () const { QString text = getText(); if (text.isEmpty()) return text; -#ifdef linux +#ifdef __linux__ QString cr = "\n"; #endif #ifdef WIN32 diff --git a/linphone-app/src/components/other/spell-checker/SpellChecker.cpp b/linphone-app/src/components/other/spell-checker/SpellChecker.cpp index 0bded389c..a46732d3c 100644 --- a/linphone-app/src/components/other/spell-checker/SpellChecker.cpp +++ b/linphone-app/src/components/other/spell-checker/SpellChecker.cpp @@ -23,6 +23,9 @@ #include #include #include +#include "components/core/CoreManager.hpp" +#include "components/settings/SettingsModel.hpp" + #ifdef WIN32 #include #endif @@ -32,15 +35,25 @@ SpellChecker::SpellChecker(QObject *parent) : QSyntaxHighlighter(parent) { errorFormater.setFontUnderline(true); errorFormater.setUnderlineColor(Qt::red); // not supported before Qt6.2 - QFontMetrics fm = QFontMetrics(QApplication::font()); + QFontMetrics fm = QFontMetrics(CoreManager::getInstance()->getSettingsModel()->getTextMessageFont()); +#ifdef __linux__ + wave = QString("‾"); + QRect boundingRect = fm.boundingRect(wave); + waveHeight = 10; + waveTopPadding = 5; +#else wave = QString(u8"\uFE4B"); QRect boundingRect = fm.boundingRect(wave); + waveHeight = 5; + waveTopPadding = 3; +#endif waveWidth = boundingRect.width(); graceTimer = new QTimer(this); graceTimer->setSingleShot(true); connect(graceTimer, SIGNAL(timeout()), SLOT(highlightAfterGracePeriod())); + mAvailable = false; setLanguage(); } @@ -112,7 +125,7 @@ void SpellChecker::highlightDocument() { QRectF boundingRect = fragment.glyphRuns(begin,length).first().boundingRect(); QPointF start = boundingRect.bottomLeft(); qreal width = boundingRect.width(); - redLines.append(QString::number(start.x())+","+QString::number(start.y())+","+QString::number(width)+","+underLine(width)); + redLines.append(QString::number(start.x())+","+QString::number(start.y())+","+QString::number(width)+","+underLine(width)+","+QString::number(waveHeight)+","+QString::number(waveTopPadding)); } if (wordActive) { hadActiveWord = wordActive; diff --git a/linphone-app/src/components/other/spell-checker/SpellChecker.hpp b/linphone-app/src/components/other/spell-checker/SpellChecker.hpp index b409c97ed..52564231a 100644 --- a/linphone-app/src/components/other/spell-checker/SpellChecker.hpp +++ b/linphone-app/src/components/other/spell-checker/SpellChecker.hpp @@ -36,6 +36,10 @@ #include #include "app/App.hpp" +#ifdef __linux__ +#include +#endif + #define SUGGESTIONS_LIMIT 10 #define GRACE_PERIOD_SECS 1.0 @@ -89,7 +93,10 @@ private: QHash ignoredOnce; QString wave; qreal waveWidth; + qreal waveHeight; + qreal waveTopPadding; qint64 mLastHightlight; + bool mAvailable; void setLanguage(); bool isWordActive(QStringList words, QString word, int index); @@ -99,6 +106,24 @@ private: #ifdef WIN32 ISpellChecker* mNativeSpellChecker = nullptr; #endif + +// ISpell linux +#ifdef __linux__ + static int gISpell_sc_read_fd; + static int gISpell_sc_write_fd; + static int gISpell_app_read_fd; + static int gISpell_app_write_fd; + static std::thread *gISpellCheckerThread; + static QHash gISpellSuggestions; + static std::string gISpellCheckeCurrentLanguage; + static std::string gIspellDictionariesFolder; + void stopISpellChecker(); + static std::shared_ptr gISpellSelfDictionary; + bool isLearnt(QString word); + bool wordValidWithFrVariants(QString word); + bool validSplittedOn(QString pattern, QString word); +#endif + }; diff --git a/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp b/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp index 92f4de3ec..0308ce25c 100644 --- a/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp +++ b/linphone-app/src/components/other/spell-checker/SpellCheckerLinux.cpp @@ -20,18 +20,194 @@ #import "SpellChecker.hpp" +#import +#include "app/paths/Paths.hpp" +#include +#include +#include +#include +#include "utils/Utils.hpp" +#include + +int SpellChecker::gISpell_sc_read_fd = 0; +int SpellChecker::gISpell_sc_write_fd = 0; +int SpellChecker::gISpell_app_read_fd = 0; +int SpellChecker::gISpell_app_write_fd = 0; +std::thread *SpellChecker::gISpellCheckerThread = nullptr; +QHash SpellChecker::gISpellSuggestions; +std::string SpellChecker::gISpellCheckeCurrentLanguage; +std::shared_ptr SpellChecker::gISpellSelfDictionary = linphone::Factory::get()->createConfig(Paths::getISpellOwnDictsDirPath()); +std::string SpellChecker::gIspellDictionariesFolder; + + +bool open_channel(int& read_fd, int& write_fd) { + int vals[2]; + int errc = pipe(vals); + if(errc) { + return false; + } else { + read_fd = vals[0]; + write_fd = vals[1]; + return true; + } +} + +void SpellChecker::stopISpellChecker() { + QString stop("__spellchecker_stop__"); + auto message = stop.toStdString(); + ssize_t amnt_written = write(gISpell_sc_write_fd, message.data(), message.size()); + if(amnt_written != message.size()) { + qWarning() << LOG_TAG << "Linux ispell unable to stop spell checker"; + } + gISpellCheckerThread->join(); + gISpellCheckerThread = nullptr; + mAvailable = false; +} void SpellChecker::setLanguage() { + + QString locale = SpellChecker::currentLanguage().toLower().mid(0,2); + + if (gISpellCheckeCurrentLanguage == locale.toStdString() && gISpellCheckerThread != nullptr) { + mAvailable = true; + return; + } + + if (gISpellCheckerThread != nullptr) // Language change + stopISpellChecker(); + + QString dict = Paths::getISpellDictsDirPath()+locale+".hash"; + gIspellDictionariesFolder = Paths::getISpellDictsDirPath().toStdString(); + + if (!QFile::exists(dict)) { + qWarning() << LOG_TAG << "Linux ispell language not supported " << SpellChecker::currentLanguage() << dict; + mAvailable = false; + return; + } + + if (!open_channel(gISpell_sc_read_fd, gISpell_sc_write_fd) || + !open_channel(gISpell_app_read_fd, gISpell_app_write_fd)) { + qWarning() << LOG_TAG << "Linux ispell language unable to open channels"; + mAvailable = false; + return; + } + + gISpellCheckeCurrentLanguage = locale.toStdString(); + gISpellCheckerThread = new std::thread(bc_spell_checker, + gIspellDictionariesFolder.data(), + gISpellCheckeCurrentLanguage.data(), + gISpell_sc_read_fd, + gISpell_app_write_fd); + + mAvailable = true; + qDebug() << LOG_TAG << "Linux ispell language loaded from " << dict; +} + +// Few special situation in French language not detected by the fr.hash. + +bool SpellChecker::wordValidWithFrVariants(QString word) { + if (word.toLower().contains("qu'")) { + QString replace = word.toLower().replace("qu'","que "); + if (isValid(replace)||validSplittedOn(" ",replace)) + return true; + } + if (word.toLower().contains("s'")) { + QString replace = word.toLower().replace("s'","se "); + if (isValid(replace)||validSplittedOn(" ",replace)) + return true; + } + return false; +} + +bool SpellChecker::validSplittedOn(QString pattern, QString word) { + if (!word.contains(pattern)) + return false; + auto split = word.split(pattern); + return isValid(split[0]) && isValid(split[1]); } bool SpellChecker::isValid(QString word) { - return true; + + if (!mAvailable || word.length() == 1 || isLearnt(word)) + return true; + + // no letters in word -> valid + QString wordCopy = word; + auto iterator = std::remove_if(wordCopy.begin(), wordCopy.end(), [](const QChar& c){ return !c.isLetter();}); + wordCopy.chop(std::distance(iterator, wordCopy.end())); + if (wordCopy.isEmpty()) + return true; + + // Some preformating + + word = word.replace("’","'"); + word = word.replace("(",""); + word = word.replace(")",""); + word = word.replace("‘","'"); + + while (word.endsWith(".") || word.endsWith("!") || word.endsWith(",") || word.endsWith(",")) + word.chop(1); + + while (word.startsWith(".") || word.startsWith("!") || word.startsWith(",") || word.startsWith(",")) { + word = word.mid(1); + } + + // submit word to ispell + auto message = word.toStdString(); + ssize_t amnt_written = write(gISpell_sc_write_fd, message.data(), message.size()); + if(amnt_written != message.size()) { + qWarning() << LOG_TAG << "Linux ispell unable to communicate with spell checker thread"; + return true; + } + + // wait and read ispell result + constexpr int buffer_size = 1024; + char buffer[buffer_size] = {0}; + ssize_t amnt_read = read(gISpell_app_read_fd, &buffer[0], buffer_size); + QString returned = QString::fromUtf8(buffer); + if (returned == "1") { + return true; + } else { + if (!gISpellSuggestions.contains(word)) { // Record returned suggestions if any + QStringList returnedUggestions = returned.split(", "); + returnedUggestions.removeFirst(); + gISpellSuggestions.insert(word,returnedUggestions); + } + return (gISpellCheckeCurrentLanguage == "fr" && wordValidWithFrVariants(word)) || + validSplittedOn("'",word) || + validSplittedOn("-",word); + } + } void SpellChecker::learn(QString word){ + QCryptographicHash hash( QCryptographicHash::Sha1 ); // Hash to avoid fancy character conflict with config format. + hash.addData(word.toUtf8()); + auto hashString = Utils::appStringToCoreString(hash.result().toHex()); + gISpellSelfDictionary->setInt("words",hashString,1); + gISpellSelfDictionary->sync(); + highlightDocument(); +} + +bool SpellChecker::isLearnt(QString word){ + QCryptographicHash hash( QCryptographicHash::Sha1 ); // Hash to avoid fancy character conflict with config format. + hash.addData(word.toUtf8()); + auto hashString = Utils::appStringToCoreString(hash.result().toHex()); + return gISpellSelfDictionary->getInt("words",hashString,0) == 1; } QStringList SpellChecker::suggestionsForWord(QString word) { QStringList suggestions; + if (!gISpellSuggestions.contains(word)) + return suggestions; + QListIterator itr (gISpellSuggestions.value(word)); + while (itr.hasNext()) { + QString suggestion = itr.next(); + if (!suggestion.contains("+")) + suggestions << suggestion; + if (suggestions.length() >= SUGGESTIONS_LIMIT) { + return suggestions; + } + } return suggestions; } diff --git a/linphone-app/src/utils/Constants.cpp b/linphone-app/src/utils/Constants.cpp index d5fa461d0..6e929af76 100644 --- a/linphone-app/src/utils/Constants.cpp +++ b/linphone-app/src/utils/Constants.cpp @@ -59,6 +59,8 @@ constexpr char Constants::PathFriendsList[]; constexpr char Constants::PathLimeDatabase[]; constexpr char Constants::PathMessageHistoryList[]; constexpr char Constants::PathZrtpSecrets[]; +constexpr char Constants::PathISpellDicts[]; +constexpr char Constants::PathISpellOwnDict[]; // Max image size in bytes. (100Kb) constexpr qint64 Constants::MaxImageSize; diff --git a/linphone-app/src/utils/Constants.hpp b/linphone-app/src/utils/Constants.hpp index 25cb2a5a6..79c7f1d6f 100644 --- a/linphone-app/src/utils/Constants.hpp +++ b/linphone-app/src/utils/Constants.hpp @@ -144,6 +144,8 @@ public: static constexpr char PathLimeDatabase[] = "/x3dh.c25519.sqlite3"; static constexpr char PathMessageHistoryList[] = "/message-history.db"; static constexpr char PathZrtpSecrets[] = "/zidcache"; + static constexpr char PathISpellDicts[] = "/" EXECUTABLE_NAME "/ispell_dictionaries/"; + static constexpr char PathISpellOwnDict[] = "/" EXECUTABLE_NAME "/ispell_own_dict"; static constexpr char LanguagePath[] = ":/languages/"; diff --git a/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml b/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml index 22131ab52..ae8be01b1 100644 --- a/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml +++ b/linphone-app/ui/modules/Common/Form/DroppableTextArea.qml @@ -238,9 +238,9 @@ Item { model: spellChecker.redLines Rectangle { clip: true - height: 5 + height: parseFloat(modelData.split(',')[4]) x: textArea.leftPadding + parseFloat(modelData.split(',')[0]) - y: textArea.topPadding + parseFloat(modelData.split(',')[1])-3 + y: textArea.topPadding + parseFloat(modelData.split(',')[1])-parseFloat(modelData.split(',')[5]) width: parseFloat(modelData.split(',')[2]) color: 'transparent' Text {