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, "