Thu, 30 Dec 2021 11:17:58 +0100
Updated copyright for 2022.
# -*- coding: utf-8 -*- # Copyright (c) 2021 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing QTextEdit and QPlainTextEdit widgets with embedded spell checking. """ import contextlib try: import enchant import enchant.tokenize from enchant.errors import TokenizerNotFoundError, DictNotFoundError from enchant.utils import trim_suggestions ENCHANT_AVAILABLE = True except ImportError: ENCHANT_AVAILABLE = False from PyQt6.QtCore import pyqtSlot, Qt, QCoreApplication from PyQt6.QtGui import ( QAction, QActionGroup, QSyntaxHighlighter, QTextBlockUserData, QTextCharFormat, QTextCursor ) from PyQt6.QtWidgets import QMenu, QTextEdit, QPlainTextEdit if ENCHANT_AVAILABLE: class SpellCheckMixin(): """ Class implementing the spell-check mixin for the widget classes. """ # don't show more than this to keep the menu manageable MaxSuggestions = 20 # default language to be used when no other is set DefaultLanguage = None # default user lists DefaultUserWordList = None DefaultUserExceptionList = None def __init__(self): """ Constructor """ self.__highlighter = EnchantHighlighter(self.document()) try: # Start with a default dictionary based on the current locale # or the configured default language. spellDict = enchant.DictWithPWL( SpellCheckMixin.DefaultLanguage, SpellCheckMixin.DefaultUserWordList, SpellCheckMixin.DefaultUserExceptionList ) except DictNotFoundError: # Use English dictionary if no locale dictionary is available # or the default one could not be found. spellDict = enchant.DictWithPWL( "en", SpellCheckMixin.DefaultUserWordList, SpellCheckMixin.DefaultUserExceptionList ) self.__highlighter.setDict(spellDict) def contextMenuEvent(self, evt): """ Protected method to handle context menu events to add a spelling suggestions submenu. @param evt reference to the context menu event @type QContextMenuEvent """ menu = self.__createSpellcheckContextMenu(evt.pos()) menu.exec(evt.globalPos()) def __createSpellcheckContextMenu(self, pos): """ Private method to create the spell-check context menu. @param pos position of the mouse pointer @type QPoint @return context menu with additional spell-check entries @rtype QMenu """ menu = self.createStandardContextMenu(pos) # Add a submenu for setting the spell-check language and # document format. menu.addSeparator() self.__addRemoveEntry(self.__cursorForPosition(pos), menu) menu.addMenu(self.__createLanguagesMenu(menu)) menu.addMenu(self.__createFormatsMenu(menu)) # Try to retrieve a menu of corrections for the right-clicked word spellMenu = self.__createCorrectionsMenu( self.__cursorForMisspelling(pos), menu) if spellMenu: menu.insertSeparator(menu.actions()[0]) menu.insertMenu(menu.actions()[0], spellMenu) return menu def __createCorrectionsMenu(self, cursor, parent=None): """ Private method to create a menu for corrections of the selected word. @param cursor reference to the text cursor @type QTextCursor @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with corrections @rtype QMenu """ if cursor is None: return None text = cursor.selectedText() suggestions = trim_suggestions( text, self.__highlighter.dict().suggest(text), SpellCheckMixin.MaxSuggestions) spellMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Spelling Suggestions"), parent) for word in suggestions: act = spellMenu.addAction(word) act.setData((cursor, word)) if suggestions: spellMenu.addSeparator() # add management entry act = spellMenu.addAction(QCoreApplication.translate( "SpellCheckMixin", "Add to Dictionary")) act.setData((cursor, text, "add")) spellMenu.triggered.connect(self.__spellMenuTriggered) return spellMenu def __addRemoveEntry(self, cursor, menu): """ Private method to create a menu entry to remove the word at the menu position. @param cursor reference to the text cursor for the misspelled word @type QTextCursor @param menu reference to the context menu @type QMenu """ if cursor is None: return text = cursor.selectedText() menu.addAction(QCoreApplication.translate( "SpellCheckMixin", "Remove '{0}' from Dictionary").format(text), lambda: self.__addToUserDict(text, "remove")) def __createLanguagesMenu(self, parent=None): """ Private method to create a menu for selecting the spell-check language. @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with spell-check languages @rtype QMenu """ curLanguage = self.__highlighter.dict().tag.lower() languageMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Language"), parent) languageActions = QActionGroup(languageMenu) for language in sorted(enchant.list_languages()): act = QAction(language, languageActions) act.setCheckable(True) act.setChecked(language.lower() == curLanguage) act.setData(language) languageMenu.addAction(act) languageMenu.triggered.connect(self.__setLanguage) return languageMenu def __createFormatsMenu(self, parent=None): """ Private method to create a menu for selecting the document format. @param parent reference to the parent widget (defaults to None) @type QWidget (optional) @return menu with document formats @rtype QMenu """ formatMenu = QMenu( QCoreApplication.translate("SpellCheckMixin", "Format"), parent) formatActions = QActionGroup(formatMenu) curFormat = self.__highlighter.chunkers() for name, chunkers in ( (QCoreApplication.translate("SpellCheckMixin", "Text"), []), (QCoreApplication.translate("SpellCheckMixin", "HTML"), [enchant.tokenize.HTMLChunker]) ): act = QAction(name, formatActions) act.setCheckable(True) act.setChecked(chunkers == curFormat) act.setData(chunkers) formatMenu.addAction(act) formatMenu.triggered.connect(self.__setFormat) return formatMenu def __cursorForPosition(self, pos): """ Private method to create a text cursor selecting the word at the given position. @param pos position of the misspelled word @type QPoint @return text cursor for the word @rtype QTextCursor """ cursor = self.cursorForPosition(pos) cursor.select(QTextCursor.SelectionType.WordUnderCursor) if cursor.hasSelection(): return cursor else: return None def __cursorForMisspelling(self, pos): """ Private method to create a text cursor selecting the misspelled word. @param pos position of the misspelled word @type QPoint @return text cursor for the misspelled word @rtype QTextCursor """ cursor = self.cursorForPosition(pos) misspelledWords = getattr(cursor.block().userData(), "misspelled", []) # If the cursor is within a misspelling, select the word for (start, end) in misspelledWords: if start <= cursor.positionInBlock() <= end: blockPosition = cursor.block().position() cursor.setPosition(blockPosition + start, QTextCursor.MoveMode.MoveAnchor) cursor.setPosition(blockPosition + end, QTextCursor.MoveMode.KeepAnchor) break if cursor.hasSelection(): return cursor else: return None def __correctWord(self, cursor, word): """ Private method to replace some misspelled text. @param cursor reference to the text cursor for the misspelled word @type QTextCursor @param word replacement text @type str """ cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(word) cursor.endEditBlock() def __addToUserDict(self, word, command): """ Private method to add a word to the user word or exclude list. @param word text to be added @type str @param command command indicating the user dictionary type @type str """ if word: dictionary = self.__highlighter.dict() if command == "add": dictionary.add(word) elif command == "remove": dictionary.remove(word) self.__highlighter.rehighlight() @pyqtSlot(QAction) def __spellMenuTriggered(self, act): """ Private slot to handle a selection of the spell menu. @param act reference to the selected action @type QAction """ data = act.data() if len(data) == 2: # replace the misspelled word self.__correctWord(*data) elif len(data) == 3: # dictionary management action _, word, command = data self.__addToUserDict(word, command) @pyqtSlot(QAction) def __setLanguage(self, act): """ Private slot to set the selected language. @param act reference to the selected action @type QAction """ language = act.data() self.setLanguage(language) @pyqtSlot(QAction) def __setFormat(self, act): """ Private slot to set the selected document format. @param act reference to the selected action @type QAction """ chunkers = act.data() self.__highlighter.setChunkers(chunkers) def setFormat(self, formatName): """ Public method to set the document format. @param formatName name of the document format @type str """ self.__highlighter.setChunkers( [enchant.tokenize.HTMLChunker] if format == "html" else [] ) def dict(self): """ Public method to get a reference to the dictionary in use. @return reference to the current dictionary @rtype enchant.Dict """ return self.__highlighter.dict() def setDict(self, spellDict): """ Public method to set the dictionary to be used. @param spellDict reference to the spell-check dictionary @type emchant.Dict """ self.__highlighter.setDict(spellDict) @pyqtSlot(str) def setLanguage(self, language): """ Public slot to set the spellchecker language. @param language language to be set @type str """ epwl = self.dict().pwl pwl = ( epwl.provider.file if isinstance(epwl, enchant.Dict) else None ) epel = self.dict().pel pel = ( epel.provider.file if isinstance(epel, enchant.Dict) else None ) self.setLanguageWithPWL(language, pwl, pel) @pyqtSlot(str, str, str) def setLanguageWithPWL(self, language, pwl, pel): """ Public slot to set the spellchecker language and associated user word lists. @param language language to be set @type str @param pwl file name of the personal word list @type str @param pel file name of the personal exclude list @type str """ self.__highlighter.setDict( enchant.DictWithPWL(language, pwl, pel) ) @classmethod def setDefaultLanguage(cls, language, pwl=None, pel=None): """ Class method to set the default spell-check language. @param language language to be set as default @type str @param pwl file name of the personal word list @type str @param pel file name of the personal exclude list @type str """ with contextlib.suppress(DictNotFoundError): cls.DefaultUserWordList = pwl cls.DefaultUserExceptionList = pel # set default language only, if a dictionary is available enchant.Dict(language) cls.DefaultLanguage = language class EnchantHighlighter(QSyntaxHighlighter): """ Class implementing a QSyntaxHighlighter subclass that consults a pyEnchant dictionary to highlight misspelled words. """ TokenFilters = (enchant.tokenize.EmailFilter, enchant.tokenize.URLFilter) # Define the spell-check style once and just assign it as necessary ErrorFormat = QTextCharFormat() ErrorFormat.setUnderlineColor(Qt.GlobalColor.red) ErrorFormat.setUnderlineStyle( QTextCharFormat.UnderlineStyle.SpellCheckUnderline) def __init__(self, *args): """ Constructor @param *args list of arguments for the QSyntaxHighlighter @type list """ QSyntaxHighlighter.__init__(self, *args) self.__spellDict = None self.__chunkers = [] def chunkers(self): """ Public method to get the chunkers in use. @return list of chunkers in use @rtype list """ return self.__chunkers def setChunkers(self, chunkers): """ Public method to set the chunkers to be used. @param chunkers chunkers to be used @type list """ self.__chunkers = chunkers self.setDict(self.dict()) def dict(self): """ Public method to get the spelling dictionary in use. @return spelling dictionary @rtype enchant.Dict """ return self.__spellDict def setDict(self, spellDict): """ Public method to set the spelling dictionary to be used. @param spellDict spelling dictionary @type enchant.Dict """ try: self.__tokenizer = enchant.tokenize.get_tokenizer( spellDict.tag, chunkers=self.__chunkers, filters=EnchantHighlighter.TokenFilters) except TokenizerNotFoundError: # Fall back to the "good for most euro languages" # English tokenizer self.__tokenizer = enchant.tokenize.get_tokenizer( chunkers=self.__chunkers, filters=EnchantHighlighter.TokenFilters) self.__spellDict = spellDict self.rehighlight() def highlightBlock(self, text): """ Public method to apply the text highlight. @param text text to be spell-checked @type str """ """Overridden QSyntaxHighlighter method to apply the highlight""" if not self.__spellDict: return # Build a list of all misspelled words and highlight them misspellings = [] for (word, pos) in self.__tokenizer(text): if not self.__spellDict.check(word): self.setFormat(pos, len(word), EnchantHighlighter.ErrorFormat) misspellings.append((pos, pos + len(word))) # Store the list so the context menu can reuse this tokenization # pass (Block-relative values so editing other blocks won't # invalidate them) data = QTextBlockUserData() data.misspelled = misspellings self.setCurrentBlockUserData(data) else: class SpellCheckMixin(): """ Class implementing the spell-check mixin for the widget classes. """ # # This is just a stub to provide the same API as the enchant enabled # one. # def __init__(self): """ Constructor """ pass def setFormat(self, formatName): """ Public method to set the document format. @param formatName name of the document format @type str """ pass def dict(self): """ Public method to get a reference to the dictionary in use. @return reference to the current dictionary @rtype enchant.Dict """ pass def setDict(self, spellDict): """ Public method to set the dictionary to be used. @param spellDict reference to the spell-check dictionary @type emchant.Dict """ pass @pyqtSlot(str) def setLanguage(self, language): """ Public slot to set the spellchecker language. @param language language to be set @type str """ pass @pyqtSlot(str, str, str) def setLanguageWithPWL(self, language, pwl, pel): """ Public slot to set the spellchecker language and associated user word lists. @param language language to be set @type str @param pwl file name of the personal word list @type str @param pel file name of the personal exclude list @type str """ pass @classmethod def setDefaultLanguage(cls, language, pwl=None, pel=None): """ Class method to set the default spell-check language. @param language language to be set as default @type str @param pwl file name of the personal word list @type str @param pel file name of the personal exclude list @type str """ pass class EricSpellCheckedPlainTextEdit(QPlainTextEdit, SpellCheckMixin): """ Class implementing a QPlainTextEdit with built-in spell checker. """ def __init__(self, *args): """ Constructor @param *args list of arguments for the QPlainTextEdit constructor. @type list """ QPlainTextEdit.__init__(self, *args) SpellCheckMixin.__init__(self) class EricSpellCheckedTextEdit(QTextEdit, SpellCheckMixin): """ Class implementing a QTextEdit with built-in spell checker. """ def __init__(self, *args): """ Constructor @param *args list of arguments for the QPlainTextEdit constructor. @type list """ QTextEdit.__init__(self, *args) SpellCheckMixin.__init__(self) self.setFormat("html") def setAcceptRichText(self, accept): """ Public method to set the text edit mode. @param accept flag indicating to accept rich text @type bool """ QTextEdit.setAcceptRichText(self, accept) self.setFormat("html" if accept else "text") if __name__ == '__main__': import sys import os from PyQt6.QtWidgets import QApplication if ENCHANT_AVAILABLE: dictPath = os.path.expanduser(os.path.join("~", ".eric7", "spelling")) SpellCheckMixin.setDefaultLanguage( "en_US", os.path.join(dictPath, "pwl.dic"), os.path.join(dictPath, "pel.dic") ) app = QApplication(sys.argv) spellEdit = EricSpellCheckedPlainTextEdit() spellEdit.show() sys.exit(app.exec())