diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index ad331896..5cac8e23 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -25,7 +25,7 @@ jobs: - name: Install Packages (apt) run: | sudo apt update - sudo apt install libenchant-dev qttools5-dev-tools + sudo apt install libenchant-dev qttools5-dev-tools aspell-en - name: Checkout Source uses: actions/checkout@v2 - name: Install Dependencies (pip) diff --git a/novelwriter/config.py b/novelwriter/config.py index c3766553..81487a16 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -358,7 +358,7 @@ class Config: # Check the availability of optional packages self._checkOptionalPackages() - if self.spellLanguage is None: + if not self.spellLanguage: self.spellLanguage = "en" # Look for a PDF version of the manual diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 8f2fcea9..d9c506cd 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -26,6 +26,8 @@ along with this program. If not, see . import os import logging +from collections import namedtuple + from novelwriter.error import logException logger = logging.getLogger(__name__) @@ -46,36 +48,47 @@ class NWSpellEnchant(): return ## - # Getters and Setters + # Properties ## + @property def spellLanguage(self): return self._spellLanguage + ## + # Setters + ## + def setLanguage(self, theLang, projectDict=None): """Load a dictionary for the language specified in the config. If that fails, we load a mock dictionary so that lookups don't - crash. + crash. Note that enchant will allow loading an empty string as + a tag, but this will fail later on. See issue #1096. """ + self._theBroker = None + self._theDict = None + self._spellLanguage = None + try: import enchant - if self._theBroker is not None: - logger.debug("Deleting old pyenchant broker") - del self._theBroker - self._theBroker = enchant.Broker() - self._theDict = self._theBroker.request_dict(theLang) - self._spellLanguage = theLang - logger.debug("Enchant spell checking for language '%s' loaded", theLang) + if theLang and enchant.dict_exists(theLang): + self._theBroker = enchant.Broker() + self._theDict = self._theBroker.request_dict(theLang) + self._spellLanguage = theLang + logger.debug("Enchant spell checking for language '%s' loaded", theLang) + else: + logger.warning("Enchant found no dictionary for language '%s'", theLang) except Exception: logger.error("Failed to load enchant spell checking for language '%s'", theLang) - self._theDict = FakeEnchant() - self._spellLanguage = None - self._readProjectDictionary(projectDict) - for pWord in self._projDict: - self._theDict.add_to_session(pWord) + if self._theDict is None: + self._theDict = FakeEnchant() + else: + self._readProjectDictionary(projectDict) + for pWord in self._projDict: + self._theDict.add_to_session(pWord) return @@ -189,6 +202,9 @@ class FakeEnchant: """Fallback for when Enchant is selected, but not installed. """ def __init__(self): + self.tag = "" + self.provider = namedtuple("provider", "name") + self.provider.name = "" return def check(self, theWord): diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 0d32689e..b127090f 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -712,7 +712,7 @@ class GuiDocEditor(QTextEdit): ), nwAlert.INFO) theMode = False - if self.spEnchant.spellLanguage() is None: + if self.spEnchant.spellLanguage is None: theMode = False self._spellCheck = theMode @@ -1991,12 +1991,14 @@ class GuiDocEditor(QTextEdit): tCheck = tInsert if tCheck in self.mainConf.fmtPadBefore: - nDelete = max(nDelete, 1) - tInsert = self._typPadChar + tInsert + if self.allowSpaceBeforeColon(theText, tCheck): + nDelete = max(nDelete, 1) + tInsert = self._typPadChar + tInsert if tCheck in self.mainConf.fmtPadAfter: - nDelete = max(nDelete, 1) - tInsert = tInsert + self._typPadChar + if self.allowSpaceBeforeColon(theText, tCheck): + nDelete = max(nDelete, 1) + tInsert = tInsert + self._typPadChar if nDelete > 0: theCursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, nDelete) @@ -2004,6 +2006,20 @@ class GuiDocEditor(QTextEdit): return + @staticmethod + def _allowSpaceBeforeColon(text, char): + """Special checker function only used by the insert space + feature for French, Spanish, etc, so it doesn't insert a + space before colons in meta data lines. See issue #1090. + """ + if char == ":" and len(text) > 1: + if text[0] == "@": + return False + if text[0] == "%": + if text[1:].lstrip()[:9].lower() == "synopsis:": + return False + return True + def _updateHeaders(self, checkPos=False, checkLevel=False): """Update the headers record and return True if anything changed, if a check flag was provided. diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 4835e667..66dd33b2 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -26,29 +26,57 @@ import pytest from mock import causeOSError from tools import readFile, writeFile -from novelwriter.core.spellcheck import NWSpellEnchant +from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant @pytest.mark.core -def testCoreSpell_Enchant(monkeypatch, tmpDir): - """Test the pyenchant spell checker +def testCoreSpell_FakeEnchant(monkeypatch): + """Test the FakeEnchant spell checker fallback. """ - wList = os.path.join(tmpDir, "wordlist.txt") - writeFile(wList, "a_word\nb_word\nc_word\n") - - # Block the enchant package (and trigger the default class) + # Make package import fail with monkeypatch.context() as mp: mp.setitem(sys.modules, "enchant", None) spChk = NWSpellEnchant() + spChk.setLanguage("en_US", "") + assert isinstance(spChk._theDict, FakeEnchant) - spChk.setLanguage("en", wList) - assert spChk.setLanguage("", "") is None - assert spChk.checkWord("") is True - assert spChk.suggestWords("") == [] + # Request a non-existent dictionary + spChk = NWSpellEnchant() + spChk.setLanguage("whatchamajig", "") + assert isinstance(spChk._theDict, FakeEnchant) + + # Request an emety language string + # See issue https://github.com/vkbo/novelWriter/issues/1096 + spChk = NWSpellEnchant() + spChk.setLanguage("", "") + assert isinstance(spChk._theDict, FakeEnchant) + + # FakeEnchant should handle requests + fkChk = FakeEnchant() + assert fkChk.tag == "" + assert fkChk.provider.name == "" + assert fkChk.check("whatchamajig") is True + assert fkChk.suggest("whatchamajig") == [] + assert fkChk.add_to_session("whatchamajig") is None + +# END Test testCoreSpell_FakeEnchant + + +@pytest.mark.core +def testCoreSpell_Enchant(monkeypatch, fncDir): + """Test the pyenchant spell checker. + """ + wList = os.path.join(fncDir, "wordlist.txt") + writeFile(wList, "a_word\nb_word\nc_word\n") + + # Break the enchant package, and check error handling + with monkeypatch.context() as mp: + mp.setitem(sys.modules, "enchant", None) + spChk = NWSpellEnchant() assert spChk.listDictionaries() == [] assert spChk.describeDict() == ("", "") - # Break the enchant package, and check error handling + # Set the dict to None, and check dictionary call error handling spChk = NWSpellEnchant() spChk.theDict = None assert spChk.checkWord("word") is True @@ -57,8 +85,9 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): # Load the proper enchant package (twice) spChk = NWSpellEnchant() - spChk.setLanguage("en", wList) - spChk.setLanguage("en", wList) + spChk.setLanguage("en_US", wList) + spChk.setLanguage("en_US", wList) + assert spChk.spellLanguage == "en_US" # Add a word to the user's dictionary assert spChk._readProjectDictionary("stuff") is False @@ -98,7 +127,39 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): assert len(dList) > 0 aTag, aName = spChk.describeDict() - assert aTag == "en" + assert aTag == "en_US" assert aName != "" # END Test testCoreSpell_Enchant + + +@pytest.mark.core +def testCoreSpell_SessionWords(fncDir): + """Test the handling of the custom word list in the spell checker. + New project sessions should not inherit the project word list from + other sessions, so this test checks that they don't bleed through. + """ + wList1 = os.path.join(fncDir, "wordlist1.txt") + wList2 = os.path.join(fncDir, "wordlist2.txt") + writeFile(wList1, "a_word\nb_word\nc_word\n") + writeFile(wList2, "d_word\ne_word\nf_word\n") + + spChk = NWSpellEnchant() + + spChk.setLanguage("en_US", wList1) + assert spChk.checkWord("a_word") is True + assert spChk.checkWord("b_word") is True + assert spChk.checkWord("c_word") is True + assert spChk.checkWord("d_word") is False + assert spChk.checkWord("e_word") is False + assert spChk.checkWord("f_word") is False + + spChk.setLanguage("en_US", wList2) + assert spChk.checkWord("a_word") is False + assert spChk.checkWord("b_word") is False + assert spChk.checkWord("c_word") is False + assert spChk.checkWord("d_word") is True + assert spChk.checkWord("e_word") is True + assert spChk.checkWord("f_word") is True + +# END Test testCoreSpell_SessionWords diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index d4a3d749..572998cb 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1190,7 +1190,7 @@ def testGuiEditor_Tags(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): @pytest.mark.gui -def testGuiEditor_WordCounters(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): """Test saving text from the editor. """ # Block message box @@ -1488,3 +1488,30 @@ def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): # qtbot.stopForInteraction() # END Test testGuiEditor_Search + + +@pytest.mark.gui +def testGuiEditor_StaticMethods(): + """Test the document editor's static methods. + """ + # Check the method that decides if it is allowed to insert a space + # before a colon using the French, Spanish, etc language feature + assert GuiDocEditor._allowSpaceBeforeColon("", "") is True + assert GuiDocEditor._allowSpaceBeforeColon("", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("some text", ":") is True + + assert GuiDocEditor._allowSpaceBeforeColon("@:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("@>", ">") is True + + assert GuiDocEditor._allowSpaceBeforeColon("%", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%:", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("%Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("% Synopsis:", ":") is False + assert GuiDocEditor._allowSpaceBeforeColon("%synopsis :", ":") is True + assert GuiDocEditor._allowSpaceBeforeColon("%Synopsis :", ":") is True + +# END Test testGuiEditor_StaticMethods diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index a27fc816..bcda180c 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -472,7 +472,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock testFile = os.path.join(outDir, "guiEditor_Main_Final_nwProject.nwx") compFile = os.path.join(refDir, "guiEditor_Main_Final_nwProject.nwx") copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) + assert cmpFiles(testFile, compFile, ignoreStart=(*XML_IGNORE, "