Linux spell checker based on ispell

This commit is contained in:
Christophe Deschamps 2023-11-06 12:57:38 +00:00
parent 92ca29b3cd
commit 19d54cb0aa
15 changed files with 316 additions and 9 deletions

5
.gitmodules vendored
View file

@ -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
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

View file

@ -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

1
external/ispell vendored Submodule

@ -0,0 +1 @@
Subproject commit 061c7e52b507f146396c3b08f289c88ca598fc2f

View file

@ -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

View file

@ -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})

View file

@ -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 ()

View file

@ -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) {

View file

@ -55,7 +55,9 @@ namespace Paths {
std::string getUserCertificatesDirPath ();
std::string getZrtpDataFilePath ();
std::string getZrtpSecretsFilePath ();
QString getISpellDictsDirPath ();
std::string getISpellOwnDictsDirPath ();
void migrate ();
}

View file

@ -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

View file

@ -23,6 +23,9 @@
#include <QTimer>
#include <QAbstractTextDocumentLayout>
#include <QTextEdit>
#include "components/core/CoreManager.hpp"
#include "components/settings/SettingsModel.hpp"
#ifdef WIN32
#include <spellcheck.h>
#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;

View file

@ -36,6 +36,10 @@
#include <QTimer>
#include "app/App.hpp"
#ifdef __linux__
#include <thread>
#endif
#define SUGGESTIONS_LIMIT 10
#define GRACE_PERIOD_SECS 1.0
@ -89,7 +93,10 @@ private:
QHash<int,QString> 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<QString,QStringList> gISpellSuggestions;
static std::string gISpellCheckeCurrentLanguage;
static std::string gIspellDictionariesFolder;
void stopISpellChecker();
static std::shared_ptr<linphone::Config> gISpellSelfDictionary;
bool isLearnt(QString word);
bool wordValidWithFrVariants(QString word);
bool validSplittedOn(QString pattern, QString word);
#endif
};

View file

@ -20,18 +20,194 @@
#import "SpellChecker.hpp"
#import <libispell.h>
#include "app/paths/Paths.hpp"
#include <unistd.h>
#include <cstdio>
#include <string>
#include <linphone++/linphone.hh>
#include "utils/Utils.hpp"
#include <QCryptographicHash>
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<QString,QStringList> SpellChecker::gISpellSuggestions;
std::string SpellChecker::gISpellCheckeCurrentLanguage;
std::shared_ptr<linphone::Config> 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<QString> 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;
}

View file

@ -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;

View file

@ -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/";

View file

@ -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 {