From c917fc9c9c104959a175ffcdd55379a4a2d11be8 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 21:49:41 +0200 Subject: [PATCH 1/8] Fix issue #1096 and remove a couple of error ourputs --- novelwriter/config.py | 2 +- novelwriter/core/spellcheck.py | 44 +++++++++++++++++++++++----------- novelwriter/gui/doceditor.py | 2 +- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/novelwriter/config.py b/novelwriter/config.py index c5159493..e52a7bd7 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -360,7 +360,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 4d35c978..59275295 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -718,7 +718,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 From 2178391bcd793fc72583a87976f7e536881c620b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 21:49:59 +0200 Subject: [PATCH 2/8] Update tests of spell check classes --- tests/test_core/test_core_spellcheck.py | 85 +++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 4835e667..01615809 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", "") + 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 @@ -59,6 +87,7 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): spChk = NWSpellEnchant() spChk.setLanguage("en", wList) spChk.setLanguage("en", wList) + assert spChk.spellLanguage == "en" # Add a word to the user's dictionary assert spChk._readProjectDictionary("stuff") is False @@ -102,3 +131,35 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): 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", 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", 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 From c23a1d224dd82b8ca4d4d2c68b936b58a315cd22 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 22:19:32 +0200 Subject: [PATCH 3/8] Install a generic English dictionary on Linux CI --- .github/workflows/test_linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index caf147ef..b95dd745 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) From 93194626c7367cd7f1980e61f0d9c3c4c32b7250 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 22:25:02 +0200 Subject: [PATCH 4/8] Add an additional ignore line on main editing test --- tests/test_gui/test_gui_guimain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 34cfaffa..cbc37dc7 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -439,7 +439,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): 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, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8, 13]) projFile = os.path.join(fncProj, "content", "031b4af5197ec.nwd") testFile = os.path.join(outDir, "guiEditor_Main_Final_031b4af5197ec.nwd") From 3ddbfb061bd40e786f0873953af1606d1ee706fd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 22:29:27 +0200 Subject: [PATCH 5/8] Make spell ckeck tests run on en_US --- tests/test_core/test_core_spellcheck.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 01615809..66dd33b2 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -37,7 +37,7 @@ def testCoreSpell_FakeEnchant(monkeypatch): with monkeypatch.context() as mp: mp.setitem(sys.modules, "enchant", None) spChk = NWSpellEnchant() - spChk.setLanguage("en", "") + spChk.setLanguage("en_US", "") assert isinstance(spChk._theDict, FakeEnchant) # Request a non-existent dictionary @@ -85,9 +85,9 @@ def testCoreSpell_Enchant(monkeypatch, fncDir): # Load the proper enchant package (twice) spChk = NWSpellEnchant() - spChk.setLanguage("en", wList) - spChk.setLanguage("en", wList) - assert spChk.spellLanguage == "en" + 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 @@ -127,7 +127,7 @@ def testCoreSpell_Enchant(monkeypatch, fncDir): assert len(dList) > 0 aTag, aName = spChk.describeDict() - assert aTag == "en" + assert aTag == "en_US" assert aName != "" # END Test testCoreSpell_Enchant @@ -146,7 +146,7 @@ def testCoreSpell_SessionWords(fncDir): spChk = NWSpellEnchant() - spChk.setLanguage("en", wList1) + 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 @@ -154,7 +154,7 @@ def testCoreSpell_SessionWords(fncDir): assert spChk.checkWord("e_word") is False assert spChk.checkWord("f_word") is False - spChk.setLanguage("en", wList2) + 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 From fcfe7507d69af4e8b4f758c6e274025f4c16221d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 23:01:03 +0200 Subject: [PATCH 6/8] Block adding spaces before colon (French language feature) in certain meta data cases --- novelwriter/gui/doceditor.py | 24 +++++++++++++++++++---- tests/test_gui/test_gui_doceditor.py | 29 +++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 59275295..f93fcab4 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1982,12 +1982,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) @@ -1995,6 +1997,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 + sapce before colons in meta data lines. + """ + 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_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 8057ca41..81fb2249 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1189,7 +1189,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_MinorMethods From 7e82461abaaf68d8fc89349c248f75a711400eeb Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 23:05:26 +0200 Subject: [PATCH 7/8] Fix comment in test --- tests/test_gui/test_gui_doceditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 81fb2249..f0d40808 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1514,4 +1514,4 @@ def testGuiEditor_StaticMethods(): assert GuiDocEditor._allowSpaceBeforeColon("%synopsis :", ":") is True assert GuiDocEditor._allowSpaceBeforeColon("%Synopsis :", ":") is True -# END Test testGuiEditor_MinorMethods +# END Test testGuiEditor_StaticMethods From 2407b61830ece297b8667fd275ea0040a3f8c8d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 17 Aug 2022 23:24:18 +0200 Subject: [PATCH 8/8] Fix typo in docstring and add reference to issue --- novelwriter/gui/doceditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index c26995ca..b127090f 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2010,7 +2010,7 @@ class GuiDocEditor(QTextEdit): def _allowSpaceBeforeColon(text, char): """Special checker function only used by the insert space feature for French, Spanish, etc, so it doesn't insert a - sapce before colons in meta data lines. + space before colons in meta data lines. See issue #1090. """ if char == ":" and len(text) > 1: if text[0] == "@":