From 8f309ca958fcf8ebfe5b4e35539f67811e44700f Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 7 Jun 2021 11:31:22 +0200 Subject: [PATCH] Add SpellChecking to NeoChat Fix #98 --- CMakeLists.txt | 2 +- src/CMakeLists.txt | 4 +- src/chatdocumenthandler.cpp | 6 ++ src/spellcheckhighlighter.cpp | 109 ++++++++++++++++++++++++++++++++++ src/spellcheckhighlighter.h | 26 ++++++++ 5 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/spellcheckhighlighter.cpp create mode 100644 src/spellcheckhighlighter.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 12c44f7ae..d8171fe23 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,7 +68,7 @@ if(ANDROID) ) else() find_package(Qt5 ${QT_MIN_VERSION} COMPONENTS Widgets) - find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO) + find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO Sonnet) set_package_properties(KF5QQC2DesktopStyle PROPERTIES TYPE RUNTIME ) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 677419841..975e163a5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,8 +43,8 @@ ecm_add_app_icon(NEOCHAT_ICON ICONS ${CMAKE_SOURCE_DIR}/128-logo.png) target_sources(neochat PRIVATE ${NEOCHAT_ICON}) if(NOT ANDROID) - target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp) - target_link_libraries(neochat PRIVATE KF5::ConfigWidgets) + target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp) + target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::SonnetCore) target_compile_definitions(neochat PRIVATE -DHAVE_COLORSCHEME) endif() diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp index 764dafc2e..b748d30fc 100644 --- a/src/chatdocumenthandler.cpp +++ b/src/chatdocumenthandler.cpp @@ -11,6 +11,9 @@ #include #include "neochatroom.h" +#ifndef Q_OS_ANDROID +#include "spellcheckhighlighter.h" +#endif ChatDocumentHandler::ChatDocumentHandler(QObject *parent) : QObject(parent) @@ -34,6 +37,9 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document) m_document->textDocument()->disconnect(this); } m_document = document; +#ifndef Q_OS_ANDROID + new SpellcheckHighlighter(m_document->textDocument()); +#endif Q_EMIT documentChanged(); } diff --git a/src/spellcheckhighlighter.cpp b/src/spellcheckhighlighter.cpp new file mode 100644 index 000000000..bd28dd005 --- /dev/null +++ b/src/spellcheckhighlighter.cpp @@ -0,0 +1,109 @@ +// Copyright (c) 2020 Christian Mollekopf +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "spellcheckhighlighter.h" + +#include +#include + +QVector split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0) +{ + QVector parts; + QTextBoundaryFinder boundaryFinder(boundary, text); + + while (boundaryFinder.position() < text.length()) { + const int start = boundaryFinder.position(); + + //Advance until we find a break that matches the mask or are at the end + for (;;) { + if (boundaryFinder.toNextBoundary() == -1) { + boundaryFinder.toEnd(); + break; + } + if (!reasonMask || boundaryFinder.boundaryReasons() & reasonMask) { + break; + } + } + + const auto length = boundaryFinder.position() - start; + + if (length < 1) { + continue; + } + parts << QStringRef{&text, start, length}; + } + return parts; +} + + +SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent) + : QSyntaxHighlighter(parent), + mSpellchecker{new Sonnet::Speller()}, + mLanguageGuesser{new Sonnet::GuessLanguage()} +{ + //Danger red from our color scheme + mErrorFormat.setForeground(QColor{"#ed1515"}); + mErrorFormat.setUnderlineColor(QColor{"#ed1515"}); + mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline); + mQuoteFormat.setForeground(QColor{"#7f8c8d"}); + + + if (!mSpellchecker->isValid()) { + qWarning() << "Spellchecker is invalid"; + } + qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries(); +} + +void SpellcheckHighlighter::autodetectLanguage(const QString &sentence) +{ + const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages()); + if (lang.isEmpty()) { + return; + } + mSpellchecker->setLanguage(lang); +} + +static bool isSpellcheckable(const QStringRef &token) +{ + if (token.isNull() || token.isEmpty()) { + return false; + } + if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) { + return false; + } + //TODO ignore urls and uppercase? + return true; +} + +void SpellcheckHighlighter::highlightBlock(const QString &text) +{ + //Avoid spellchecking quotes + if (text.isEmpty() || text.at(0) == QChar{'>'}) { + setFormat(0, text.length(), mQuoteFormat); + return; + } + for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) { + //Avoid spellchecking quotes + if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) { + continue; + } + + const auto sentence = QString::fromRawData(sentenceRef.data(), sentenceRef.length()); + + autodetectLanguage(sentence); + + const int offset = sentenceRef.position(); + for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) { + //Avoid spellchecking words in progress + //FIXME this will also prevent spellchecking a single word on a line. + if (offset + wordRef.position() + wordRef.length() >= text.length()) { + continue; + } + if (isSpellcheckable(wordRef)) { + const auto word = QString::fromRawData(wordRef.data(), wordRef.length()); + const auto format = mSpellchecker->isMisspelled(word) ? mErrorFormat : QTextCharFormat{}; + setFormat(offset + wordRef.position(), wordRef.length(), format); + } + } + } +} diff --git a/src/spellcheckhighlighter.h b/src/spellcheckhighlighter.h new file mode 100644 index 000000000..5a03ae016 --- /dev/null +++ b/src/spellcheckhighlighter.h @@ -0,0 +1,26 @@ +// Copyright (c) 2020 Christian Mollekopf +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +class SpellcheckHighlighter: public QSyntaxHighlighter +{ +public: + SpellcheckHighlighter(QTextDocument *parent); + +protected: + void highlightBlock(const QString &text) override; + +private: + void autodetectLanguage(const QString &sentence); + QTextCharFormat mErrorFormat; + QTextCharFormat mQuoteFormat; + QScopedPointer mSpellchecker; + QScopedPointer mLanguageGuesser; +}; +