Add support for pyspellchecker as an alternative to PyEnchant

This modifies the Spellchecker abstraction to add a new dictionary support, with
support for pyspellchecker. It also changes the main UI so that multiple libraries
can be supported and dictionaries provided to the user. The custom dictionary of
pyspellchecker has to be handled manually, and the performance and words of this
library isn't on par with PyEnchant, but at least it works with 64 bits.

Fixes #505
This commit is contained in:
Youness Alaoui 2019-02-21 18:50:28 -05:00 committed by Curtis Gedak
parent d0f02cb2a7
commit 20c5586a6c
3 changed files with 205 additions and 28 deletions

View file

@ -12,7 +12,6 @@ from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import qApp, QTextEdit
from manuskript.enums import Outline
from manuskript.functions.spellchecker import Spellchecker
# Used to detect multiple connections
AUC = Qt.AutoConnection | Qt.UniqueConnection
@ -398,3 +397,6 @@ def inspect():
s.lineno,
s.function))
print(" " + "".join(s.code_context))
# Spellchecker loads writablePath from this file, so we need to load it after they get defined
from manuskript.functions.spellchecker import Spellchecker

View file

@ -1,45 +1,111 @@
import os, gzip, json
from PyQt5.QtCore import QLocale
from collections import OrderedDict
from manuskript.functions import writablePath
try:
import enchant
except ImportError:
enchant = None
try:
import spellchecker as pyspellchecker
except ImportError:
pyspellchecker = None
class Spellchecker:
dictionaries = {}
# In order of priority
implementations = []
def __init__(self):
pass
@staticmethod
def isInstalled():
return enchant is not None
for impl in Spellchecker.implementations:
if impl.isInstalled():
return True
return False
@staticmethod
def supportedLibraries():
libs = []
for impl in Spellchecker.implementations:
libs.append(impl.getLibraryName())
return libs
@staticmethod
def availableLibraries():
ret = []
for impl in Spellchecker.implementations:
if impl.isInstalled():
ret.append(impl.getLibraryName())
return ret
@staticmethod
def availableDictionaries():
return EnchantDictionary.availableDictionaries()
dictionaries = OrderedDict()
for impl in Spellchecker.implementations:
dictionaries[impl.getLibraryName()] = impl.availableDictionaries()
return dictionaries
@staticmethod
def normalizeDictName(lib, dictionary):
return "{}:{}".format(lib, dictionary)
@staticmethod
def getDefaultDictionary():
return EnchantDictionary.getDefaultDictionary()
for impl in Spellchecker.implementations:
default = impl.getDefaultDictionary()
if default:
return Spellchecker.normalizeDictName(impl.getLibraryName(), default)
return None
@staticmethod
def getLibraryURL():
return EnchantDictionary.getLibraryURL()
def getLibraryURL(lib=None):
urls = {}
for impl in Spellchecker.implementations:
urls[impl.getLibraryName()] = impl.getLibraryURL()
if lib:
return urls.get(lib, None)
return urls
@staticmethod
def getResourcesPath(library):
path = os.path.join(writablePath(), "resources", "dictionaries", library)
if not os.path.exists(path):
os.makedirs(path)
return path
@staticmethod
def getDictionary(dictionary):
if not dictionary:
dictionary = Spellchecker.getDefaultDictionary()
if not dictionary:
return None
values = dictionary.split(":", 1)
if len(values) == 1:
(lib, name) = (Spellchecker.implementations[0].getLibraryName(), dictionary)
dictionary = Spellchecker.normalizeDictName(lib, name)
else:
(lib, name) = values
try:
d = Spellchecker.dictionaries.get(dictionary, None)
if d is None:
d = EnchantDictionary(dictionary)
Spellchecker.dictionaries[d.name] = d
for impl in Spellchecker.implementations:
if lib == impl.getLibraryName():
d = impl(name)
Spellchecker.dictionaries[dictionary] = d
break
return d
except Exception as e:
return None
pass
return None
class BasicDictionary:
def __init__(self, name):
@ -57,6 +123,10 @@ class BasicDictionary:
def getLibraryURL():
raise NotImplemented
@staticmethod
def isInstalled():
raise NotImplemented
@staticmethod
def getDefaultDictionary():
raise NotImplemented
@ -95,21 +165,25 @@ class EnchantDictionary(BasicDictionary):
@staticmethod
def getLibraryName():
return "pyEnchant"
return "PyEnchant"
@staticmethod
def getLibraryURL():
return "https://pypi.org/project/pyenchant/"
@staticmethod
def isInstalled():
return enchant is not None
@staticmethod
def availableDictionaries():
if enchant:
if EnchantDictionary.isInstalled():
return list(map(lambda i: str(i[0]), enchant.list_dicts()))
return []
@staticmethod
def getDefaultDictionary():
if not enchant:
if not EnchantDictionary.isInstalled():
return None
default_locale = enchant.get_default_language()
@ -137,3 +211,90 @@ class EnchantDictionary(BasicDictionary):
def removeWord(self, word):
self._dict.remove(word)
Spellchecker.implementations.append(EnchantDictionary)
class PySpellcheckerDictionary(BasicDictionary):
def __init__(self, name):
self._lang = name
if not self._lang:
self._lang = self.getDefaultDictionary()
self._dict = pyspellchecker.SpellChecker(self._lang)
self._customDict = None
customPath = self.getCustomDictionaryPath()
try:
self._customDict = pyspellchecker.SpellChecker(local_dictionary=customPath)
self._dict.word_frequency.load_dictionary(customPath)
except:
# If error loading the file, overwrite with empty dictionary
with gzip.open(customPath, "wt") as f:
f.write(json.dumps({}))
self._customDict = pyspellchecker.SpellChecker(local_dictionary=customPath)
self._dict.word_frequency.load_dictionary(customPath)
def getCustomDictionaryPath(self):
return os.path.join(Spellchecker.getResourcesPath(self.getLibraryName()), "{}.json.gz".format(self._lang))
@property
def name(self):
return self._lang
@staticmethod
def getLibraryName():
return "pyspellchecker"
@staticmethod
def getLibraryURL():
return "https://pyspellchecker.readthedocs.io/en/latest/"
@staticmethod
def isInstalled():
return pyspellchecker is not None
@staticmethod
def availableDictionaries():
if PySpellcheckerDictionary.isInstalled():
# TODO: If pyspellchecker eventually adds a way to get this list
# programmatically or if the list changes, we need to update it here
return ["de", "en", "es", "fr", "pt"]
return []
@staticmethod
def getDefaultDictionary():
if not PySpellcheckerDictionary.isInstalled():
return None
default_locale = QLocale.system().name()
if default_locale:
default_locale = default_locale[0:2]
if default_locale is None:
default_locale = "en"
return default_locale
def isMisspelled(self, word):
return len(self._dict.unknown([word])) > 0
def getSuggestions(self, word):
candidates = self._dict.candidates(word)
if word in candidates:
candidates.remove(word)
return candidates
def isCustomWord(self, word):
return len(self._customDict.known([word])) > 0
def addWord(self, word):
self._dict.word_frequency.add(word)
self._customDict.word_frequency.add(word)
self._customDict.export(self.getCustomDictionaryPath(), gzipped=True)
def removeWord(self, word):
self._dict.word_frequency.remove(word)
self._customDict.word_frequency.remove(word)
self._customDict.export(self.getCustomDictionaryPath(), gzipped=True)
Spellchecker.implementations.append(PySpellcheckerDictionary)

View file

@ -1255,10 +1255,15 @@ class MainWindow(QMainWindow, Ui_MainWindow):
else:
# No Spell check support
self.actSpellcheck.setVisible(False)
a = QAction(self.tr("Install PyEnchant to use spellcheck"), self)
a.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxWarning))
a.triggered.connect(self.openPyEnchantWebPage, F.AUC)
self.menuTools.addAction(a)
for lib in Spellchecker.supportedLibraries():
a = QAction(self.tr("Install {} to use spellcheck").format(lib), self)
a.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxWarning))
# Need to bound the lib argument otherwise the lambda uses the same lib value across all calls
def gen_slot_cb(l):
return lambda: self.openSpellcheckWebPage(l)
a.triggered.connect(gen_slot_cb(lib), F.AUC)
self.menuTools.addAction(a)
###############################################################################
# SPELLCHECK
@ -1270,16 +1275,25 @@ class MainWindow(QMainWindow, Ui_MainWindow):
return
self.menuDict.clear()
for i in Spellchecker.availableDictionaries():
a = QAction(i, self)
a.setCheckable(True)
if settings.dict is None:
settings.dict = Spellchecker.getDefaultDictionary()
if i == settings.dict:
a.setChecked(True)
a.triggered.connect(self.setDictionary, F.AUC)
self.menuDictGroup.addAction(a)
for lib, dicts in Spellchecker.availableDictionaries().items():
if len(dicts) > 1:
a = QAction(lib, self)
else:
a = QAction(self.tr("{} is not installed").format(lib), self)
a.setEnabled(False)
self.menuDict.addAction(a)
for i in dicts:
a = QAction(i, self)
a.data = lib
a.setCheckable(True)
if settings.dict is None:
settings.dict = Spellchecker.getDefaultDictionary()
if Spellchecker.normalizeDictName(lib, i) == settings.dict:
a.setChecked(True)
a.triggered.connect(self.setDictionary, F.AUC)
self.menuDictGroup.addAction(a)
self.menuDict.addAction(a)
self.menuDict.addSeparator()
def setDictionary(self):
if not Spellchecker.isInstalled():
@ -1288,15 +1302,15 @@ class MainWindow(QMainWindow, Ui_MainWindow):
for i in self.menuDictGroup.actions():
if i.isChecked():
# self.dictChanged.emit(i.text().replace("&", ""))
settings.dict = i.text().replace("&", "")
settings.dict = Spellchecker.normalizeDictName(i.data, i.text().replace("&", ""))
# Find all textEditView from self, and toggle spellcheck
for w in self.findChildren(textEditView, QRegExp(".*"),
Qt.FindChildrenRecursively):
w.setDict(settings.dict)
def openPyEnchantWebPage(self):
F.openURL(Spellchecker.getLibraryURL())
def openSpellcheckWebPage(self, lib):
F.openURL(Spellchecker.getLibraryURL(lib))
def toggleSpellcheck(self, val):
settings.spellcheck = val