1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2008 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing the spell checker for the editor component. 8 9The spell checker is based on pyenchant. 10""" 11 12import os 13import contextlib 14 15from PyQt5.QtCore import QTimer, QObject 16 17import Preferences 18import Utilities 19 20with contextlib.suppress(ImportError, AttributeError, OSError): 21 import enchant 22 23 24class SpellChecker(QObject): 25 """ 26 Class implementing a pyenchant based spell checker. 27 """ 28 # class attributes to be used as defaults 29 _spelling_lang = None 30 _spelling_dict = None 31 32 def __init__(self, editor, indicator, defaultLanguage=None, 33 checkRegion=None): 34 """ 35 Constructor 36 37 @param editor reference to the editor object (QScintilla.Editor) 38 @param indicator spell checking indicator 39 @param defaultLanguage the language to be used as the default 40 (string). The string should be in language locale format 41 (e.g. en_US, de). 42 @param checkRegion reference to a function to check for a valid 43 region 44 """ 45 super().__init__(editor) 46 47 self.editor = editor 48 self.indicator = indicator 49 if defaultLanguage is not None: 50 self.setDefaultLanguage(defaultLanguage) 51 if checkRegion is not None: 52 self.__checkRegion = checkRegion 53 else: 54 self.__checkRegion = lambda r: True 55 self.minimumWordSize = 3 56 self.lastCheckedLine = -1 57 58 self.__ignoreWords = [] 59 self.__replaceWords = {} 60 61 @classmethod 62 def getAvailableLanguages(cls): 63 """ 64 Class method to get all available languages. 65 66 @return list of available languages (list of strings) 67 """ 68 with contextlib.suppress(NameError): 69 return enchant.list_languages() 70 return [] 71 72 @classmethod 73 def isAvailable(cls): 74 """ 75 Class method to check, if spellchecking is available. 76 77 @return flag indicating availability (boolean) 78 """ 79 if Preferences.getEditor("SpellCheckingEnabled"): 80 with contextlib.suppress(NameError, AttributeError): 81 return len(enchant.list_languages()) > 0 82 return False 83 84 @classmethod 85 def getDefaultPath(cls, isException=False): 86 """ 87 Class method to get the default path names of the user dictionaries. 88 89 @param isException flag indicating to return the name of the default 90 exception dictionary (boolean) 91 @return file name of the default user dictionary or the default user 92 exception dictionary (string) 93 """ 94 if isException: 95 return os.path.join( 96 Utilities.getConfigDir(), "spelling", "pel.dic") 97 else: 98 return os.path.join( 99 Utilities.getConfigDir(), "spelling", "pwl.dic") 100 101 @classmethod 102 def getUserDictionaryPath(cls, isException=False): 103 """ 104 Class method to get the path name of a user dictionary file. 105 106 @param isException flag indicating to return the name of the user 107 exception dictionary (boolean) 108 @return file name of the user dictionary or the user exception 109 dictionary (string) 110 """ 111 if isException: 112 dicFile = Preferences.getEditor("SpellCheckingPersonalExcludeList") 113 if not dicFile: 114 dicFile = SpellChecker.getDefaultPath(True) 115 else: 116 dicFile = Preferences.getEditor("SpellCheckingPersonalWordList") 117 if not dicFile: 118 dicFile = SpellChecker.getDefaultPath() 119 return dicFile 120 121 @classmethod 122 def _getDict(cls, lang, pwl="", pel=""): 123 """ 124 Protected class method to get a new dictionary. 125 126 @param lang the language to be used as the default (string). 127 The string should be in language locale format (e.g. en_US, de). 128 @param pwl name of the personal/project word list (string) 129 @param pel name of the personal/project exclude list (string) 130 @return reference to the dictionary (enchant.Dict) 131 """ 132 if not pwl: 133 pwl = SpellChecker.getUserDictionaryPath() 134 d = os.path.dirname(pwl) 135 if not os.path.exists(d): 136 os.makedirs(d) 137 138 if not pel: 139 pel = SpellChecker.getUserDictionaryPath(False) 140 d = os.path.dirname(pel) 141 if not os.path.exists(d): 142 os.makedirs(d) 143 144 try: 145 d = enchant.DictWithPWL(lang, pwl, pel) 146 except Exception: 147 # Catch all exceptions, because if pyenchant isn't available, you 148 # can't catch the enchant.DictNotFound error. 149 d = None 150 return d 151 152 @classmethod 153 def setDefaultLanguage(cls, language): 154 """ 155 Class method to set the default language. 156 157 @param language the language to be used as the default (string). 158 The string should be in language locale format (e.g. en_US, de). 159 """ 160 cls._spelling_lang = language 161 cls._spelling_dict = cls._getDict(language) 162 163 def setLanguage(self, language, pwl="", pel=""): 164 """ 165 Public method to set the current language. 166 167 @param language the language to be used as the default (string). 168 The string should be in language locale format (e.g. en_US, de). 169 @param pwl name of the personal/project word list (string) 170 @param pel name of the personal/project exclude list (string) 171 """ 172 self._spelling_lang = language 173 self._spelling_dict = self._getDict(language, pwl=pwl, 174 pel=pel) 175 176 def getLanguage(self): 177 """ 178 Public method to get the current language. 179 180 @return current language in language locale format (string) 181 """ 182 return self._spelling_lang 183 184 def setMinimumWordSize(self, size): 185 """ 186 Public method to set the minimum word size. 187 188 @param size minimum word size (integer) 189 """ 190 if size > 0: 191 self.minimumWordSize = size 192 193 def __getNextWord(self, pos, endPosition): 194 """ 195 Private method to get the next word in the text after the given 196 position. 197 198 @param pos position to start word extraction (integer) 199 @param endPosition position to stop word extraction (integer) 200 @return tuple of three values (the extracted word (string), 201 start position (integer), end position (integer)) 202 """ 203 if pos < 0 or pos >= endPosition: 204 return "", -1, -1 205 206 ch = self.editor.charAt(pos) 207 # 1. skip non-word characters 208 while pos < endPosition and not ch.isalnum(): 209 pos = self.editor.positionAfter(pos) 210 ch = self.editor.charAt(pos) 211 if pos == endPosition: 212 return "", -1, -1 213 startPos = pos 214 215 # 2. extract the word 216 word = "" 217 while pos < endPosition and ch.isalnum(): 218 word += ch 219 pos = self.editor.positionAfter(pos) 220 ch = self.editor.charAt(pos) 221 endPos = pos 222 if word.isdigit(): 223 return self.__getNextWord(endPos, endPosition) 224 else: 225 return word, startPos, endPos 226 227 def getContext(self, wordStart, wordEnd): 228 """ 229 Public method to get the context of a faulty word. 230 231 @param wordStart the starting position of the word (integer) 232 @param wordEnd the ending position of the word (integer) 233 @return tuple of the leading and trailing context (string, string) 234 """ 235 sline, sindex = self.editor.lineIndexFromPosition(wordStart) 236 eline, eindex = self.editor.lineIndexFromPosition(wordEnd) 237 text = self.editor.text(sline) 238 return (text[:sindex], text[eindex:]) 239 240 def getError(self): 241 """ 242 Public method to get information about the last error found. 243 244 @return tuple of last faulty word (string), starting position of the 245 faulty word (integer) and ending position of the faulty word 246 (integer) 247 """ 248 return (self.word, self.wordStart, self.wordEnd) 249 250 def initCheck(self, startPos, endPos): 251 """ 252 Public method to initialize a spell check. 253 254 @param startPos position to start at (integer) 255 @param endPos position to end at (integer) 256 @return flag indicating successful initialization (boolean) 257 """ 258 if startPos == endPos: 259 return False 260 261 spell = self._spelling_dict 262 if spell is None: 263 return False 264 265 self.editor.clearIndicatorRange( 266 self.indicator, startPos, endPos - startPos) 267 268 self.pos = startPos 269 self.endPos = endPos 270 self.word = "" 271 self.wordStart = -1 272 self.wordEnd = -1 273 return True 274 275 def __checkDocumentPart(self, startPos, endPos): 276 """ 277 Private method to check some part of the document. 278 279 @param startPos position to start at (integer) 280 @param endPos position to end at (integer) 281 """ 282 if not self.initCheck(startPos, endPos): 283 return 284 285 while True: 286 try: 287 next(self) 288 self.editor.setIndicatorRange(self.indicator, self.wordStart, 289 self.wordEnd - self.wordStart) 290 except StopIteration: 291 break 292 293 def __incrementalCheck(self): 294 """ 295 Private method to check the document incrementally. 296 """ 297 if self.lastCheckedLine < 0: 298 return 299 300 linesChunk = Preferences.getEditor("AutoSpellCheckChunkSize") 301 self.checkLines(self.lastCheckedLine, 302 self.lastCheckedLine + linesChunk) 303 self.lastCheckedLine = self.lastCheckedLine + linesChunk + 1 304 if self.lastCheckedLine >= self.editor.lines(): 305 self.lastCheckedLine = -1 306 else: 307 QTimer.singleShot(0, self.__incrementalCheck) 308 309 def checkWord(self, pos, atEnd=False): 310 """ 311 Public method to check the word at position pos. 312 313 @param pos position to check at (integer) 314 @param atEnd flag indicating the position is at the end of the word 315 to check (boolean) 316 """ 317 spell = self._spelling_dict 318 if spell is None: 319 return 320 321 if atEnd: 322 pos = self.editor.positionBefore(pos) 323 324 if pos >= 0 and self.__checkRegion(pos): 325 pos0 = pos 326 pos1 = 0xffffffff 327 if not self.editor.charAt(pos).isalnum(): 328 line, index = self.editor.lineIndexFromPosition(pos) 329 self.editor.clearIndicator( 330 self.indicator, line, index, line, index + 1) 331 pos1 = self.editor.positionAfter(pos) 332 pos0 = self.editor.positionBefore(pos) 333 334 for pos in [pos0, pos1]: 335 if self.editor.charAt(pos).isalnum(): 336 line, index = self.editor.lineIndexFromPosition(pos) 337 word = self.editor.getWord(line, index, useWordChars=False) 338 if len(word) >= self.minimumWordSize: 339 try: 340 ok = spell.check(word) 341 except enchant.errors.Error: 342 ok = True 343 else: 344 ok = True 345 start, end = self.editor.getWordBoundaries( 346 line, index, useWordChars=False) 347 if ok: 348 self.editor.clearIndicator( 349 self.indicator, line, start, line, end) 350 else: 351 # spell check indicated an error 352 self.editor.setIndicator( 353 self.indicator, line, start, line, end) 354 355 def checkLines(self, firstLine, lastLine): 356 """ 357 Public method to check some lines of text. 358 359 @param firstLine line number of first line to check (integer) 360 @param lastLine line number of last line to check (integer) 361 """ 362 startPos = self.editor.positionFromLineIndex(firstLine, 0) 363 364 if lastLine >= self.editor.lines(): 365 lastLine = self.editor.lines() - 1 366 endPos = self.editor.lineEndPosition(lastLine) 367 368 self.__checkDocumentPart(startPos, endPos) 369 370 def checkDocument(self): 371 """ 372 Public method to check the complete document. 373 """ 374 self.__checkDocumentPart(0, self.editor.length()) 375 376 def checkDocumentIncrementally(self): 377 """ 378 Public method to check the document incrementally. 379 """ 380 spell = self._spelling_dict 381 if spell is None: 382 return 383 384 if Preferences.getEditor("AutoSpellCheckingEnabled"): 385 self.lastCheckedLine = 0 386 QTimer.singleShot(0, self.__incrementalCheck) 387 388 def stopIncrementalCheck(self): 389 """ 390 Public method to stop an incremental check. 391 """ 392 self.lastCheckedLine = -1 393 394 def checkSelection(self): 395 """ 396 Public method to check the current selection. 397 """ 398 selStartLine, selStartIndex, selEndLine, selEndIndex = ( 399 self.editor.getSelection() 400 ) 401 self.__checkDocumentPart( 402 self.editor.positionFromLineIndex(selStartLine, selStartIndex), 403 self.editor.positionFromLineIndex(selEndLine, selEndIndex) 404 ) 405 406 def checkCurrentPage(self): 407 """ 408 Public method to check the currently visible page. 409 """ 410 startLine = self.editor.firstVisibleLine() 411 endLine = startLine + self.editor.linesOnScreen() 412 self.checkLines(startLine, endLine) 413 414 def clearAll(self): 415 """ 416 Public method to clear all spelling markers. 417 """ 418 self.editor.clearIndicatorRange( 419 self.indicator, 0, self.editor.length()) 420 421 def getSuggestions(self, word): 422 """ 423 Public method to get suggestions for the given word. 424 425 @param word word to get suggestions for (string) 426 @return list of suggestions (list of strings) 427 """ 428 suggestions = [] 429 spell = self._spelling_dict 430 if spell and len(word) >= self.minimumWordSize: 431 with contextlib.suppress(enchant.errors.Error): 432 suggestions = spell.suggest(word) 433 return suggestions 434 435 def add(self, word=None): 436 """ 437 Public method to add a word to the personal word list. 438 439 @param word word to add (string) 440 """ 441 spell = self._spelling_dict 442 if spell: 443 if word is None: 444 word = self.word 445 spell.add(word) 446 447 def remove(self, word): 448 """ 449 Public method to add a word to the personal exclude list. 450 451 @param word word to add (string) 452 """ 453 spell = self._spelling_dict 454 if spell: 455 spell.remove(word) 456 457 def ignoreAlways(self, word=None): 458 """ 459 Public method to tell the checker, to always ignore the given word 460 or the current word. 461 462 @param word word to be ignored (string) 463 """ 464 if word is None: 465 word = self.word 466 if word not in self.__ignoreWords: 467 self.__ignoreWords.append(word) 468 469 def replace(self, replacement): 470 """ 471 Public method to tell the checker to replace the current word with 472 the replacement string. 473 474 @param replacement replacement string (string) 475 """ 476 sline, sindex = self.editor.lineIndexFromPosition(self.wordStart) 477 eline, eindex = self.editor.lineIndexFromPosition(self.wordEnd) 478 self.editor.setSelection(sline, sindex, eline, eindex) 479 self.editor.beginUndoAction() 480 self.editor.removeSelectedText() 481 self.editor.insert(replacement) 482 self.editor.endUndoAction() 483 self.pos += len(replacement) - len(self.word) 484 485 def replaceAlways(self, replacement): 486 """ 487 Public method to tell the checker to always replace the current word 488 with the replacement string. 489 490 @param replacement replacement string (string) 491 """ 492 self.__replaceWords[self.word] = replacement 493 self.replace(replacement) 494 495 ################################################################## 496 ## Methods below implement the iterator protocol 497 ################################################################## 498 499 def __iter__(self): 500 """ 501 Special method to create an iterator. 502 503 @return self 504 """ 505 return self 506 507 def __next__(self): 508 """ 509 Special method to advance to the next error. 510 511 @return self 512 @exception StopIteration raised to indicate the end of the iteration 513 """ 514 spell = self._spelling_dict 515 if spell: 516 while self.pos < self.endPos and self.pos >= 0: 517 word, wordStart, wordEnd = self.__getNextWord( 518 self.pos, self.endPos) 519 self.pos = wordEnd 520 if ( 521 (wordEnd - wordStart) >= self.minimumWordSize and 522 self.__checkRegion(wordStart) 523 ): 524 with contextlib.suppress(enchant.errors.Error): 525 if spell.check(word): 526 continue 527 if word in self.__ignoreWords: 528 continue 529 self.word = word 530 self.wordStart = wordStart 531 self.wordEnd = wordEnd 532 if word in self.__replaceWords: 533 self.replace(self.__replaceWords[word]) 534 continue 535 return self 536 537 raise StopIteration 538